@purityjs/signals 0.0.0-stage → 0.5.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/signals.ts ADDED
@@ -0,0 +1,1134 @@
1
+ import {
2
+ type ComponentContext,
3
+ getCurrentComponent,
4
+ getCurrentContext,
5
+ popContext,
6
+ pushContext,
7
+ type Scope,
8
+ } from './scope.ts';
9
+
10
+ declare function queueMicrotask(callback: () => void): void;
11
+ declare const console: {
12
+ error(...args: unknown[]): void;
13
+ warn(...args: unknown[]): void;
14
+ };
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Reactivity core
18
+ //
19
+ // Push-pull, version-tracked graph. Plain-object nodes (no classes, no
20
+ // private slots) so V8 can inline the shape — every State and Computed is a
21
+ // fixed-shape literal, allocations land on the same hidden class.
22
+ //
23
+ // Status states:
24
+ // CLEAN — value is current, all sources consistent
25
+ // CHECK — an ancestor was DIRTY, so we MIGHT be dirty; resolve by walking
26
+ // sources and comparing versions before re-running
27
+ // DIRTY — known stale; must re-run to refresh value
28
+ //
29
+ // Propagation runs in two phases (Solid-style):
30
+ // 1. write() bumps version, marks direct observers DIRTY, transitive CHECK,
31
+ // and queues effect nodes onto the pending list.
32
+ // 2. flush() pulls each pending effect: walks its sources, recursively
33
+ // updates dirty computeds top-down, then re-runs the effect only if a
34
+ // source's version actually changed. CHECK→CLEAN without a re-run is
35
+ // the glitch-freedom mechanism.
36
+ // ---------------------------------------------------------------------------
37
+
38
+ // const enum would inline these but Node's TS strip mode rejects them.
39
+ const STATUS_CLEAN = 0;
40
+ const STATUS_CHECK = 1;
41
+ const STATUS_DIRTY = 2;
42
+ type Status = 0 | 1 | 2;
43
+
44
+ interface StateNode<T> {
45
+ /** Discriminator: state has no fn. */
46
+ fn: null;
47
+ value: T;
48
+ /** Bumps every time `value` actually changes. Consumers cache it to detect change without re-running fn. */
49
+ version: number;
50
+ observers: ComputedNode[] | null;
51
+ }
52
+
53
+ interface ComputedNode {
54
+ fn: () => unknown;
55
+ value: unknown;
56
+ version: number;
57
+ status: Status;
58
+ sources: AnyNode[] | null;
59
+ /** Interleaved source versions and observer back-pointers. */
60
+ sourceMeta: number[] | null;
61
+ observers: ComputedNode[] | null;
62
+ /** Effects: disposers of reactive work created during the last run (nested
63
+ * watches, template bindings, computes), ending with the cleanup function
64
+ * the body returned. An effect is the current scope while it runs, so this
65
+ * is its `Scope.disposers`. Released before the next run and on dispose. */
66
+ disposers: (() => void)[] | null;
67
+ /** Effects: the component they were created under. An effect is the current
68
+ * scope when it re-runs in a later flush, after that component rendered;
69
+ * this keeps component-only APIs called from the body working. */
70
+ component: ComponentContext | null;
71
+ /** True for watch effects (must re-run for side effects); false for compute (lazy). */
72
+ isEffect: boolean;
73
+ disposed: boolean;
74
+ /** Set when the last fn() run threw (computes only; effects rethrow to flush).
75
+ * Boxed so a thrown `undefined`/`null` is still distinguishable from "no error".
76
+ * Checked on the read path only when set; cleared by the next successful run. */
77
+ error: { value: unknown } | null;
78
+ /** Effects: the flush that last counted a run of this node, and how many runs it has counted in that flush. */
79
+ flushId: number;
80
+ flushRuns: number;
81
+ }
82
+
83
+ type AnyNode = StateNode<unknown> | ComputedNode;
84
+
85
+ let activeListener: ComputedNode | null = null;
86
+ /** Position cursor while activeListener is running fn(); enables in-place source slot reuse. */
87
+ let activeSourceIdx = 0;
88
+ let batchDepth = 0;
89
+ let microtaskScheduled = false;
90
+ const pendingEffects: ComputedNode[] = [];
91
+
92
+ // Re-runs allowed for one effect within one flush. A flush normally runs each
93
+ // dirtied effect once; a second or third run comes from an upstream effect
94
+ // writing again in the same cascade. 1000 is far beyond any such chain, and a
95
+ // feedback loop reaches it in well under a millisecond per cycle, so it trips
96
+ // quickly. Trade-off: one effect fed by more than 1000 separate writes inside a
97
+ // single flush would trip; that is treated as a loop.
98
+ const MAX_EFFECT_REPEATS = 1000;
99
+ let flushSeq = 0;
100
+ const MAX_EFFECT_DEPTH = 100;
101
+ let effectDepth = 0;
102
+
103
+ // ---------------------------------------------------------------------------
104
+ // Inspection registry
105
+ //
106
+ // Powers the `globalThis.__purity_inspect__` hook (see `docs/debugging.md`
107
+ // and ADR-0002) so consumers can debug live apps without rebuilding for
108
+ // development. Gated on `__DEV__` (below) rather than always present: a
109
+ // consuming app's own production build replaces `process.env.NODE_ENV` with
110
+ // the literal "production" (standard behavior for Vite/esbuild/webpack app
111
+ // builds — NOT this package's own library build, which leaves it
112
+ // unresolved on purpose so the decision is the consumer's, at their build
113
+ // time, not baked in here), which folds `__DEV__` to `false` and lets that
114
+ // build's minifier drop every branch gated on it — this file's own
115
+ // WeakRef/FinalizationRegistry bookkeeping and the whole inspector-hook
116
+ // installation block at the end of this file.
117
+ // ---------------------------------------------------------------------------
118
+
119
+ // A minimal local ambient type for the one Node-style global this
120
+ // browser-targeting package reads, so it doesn't need to depend on
121
+ // @types/node (which would otherwise pull in a whole browser-irrelevant
122
+ // global surface) just for this. Deliberately unguarded by a
123
+ // `typeof process !== 'undefined'` check: that check can't itself be
124
+ // constant-folded by a bundler, which would leave `__DEV__` unresolvable at
125
+ // build time and defeat the dead-code elimination this exists for. Same
126
+ // assumption every other library using this pattern (React included)
127
+ // makes: something in the build pipeline replaces `process.env.NODE_ENV`
128
+ // before this code reaches a real browser.
129
+ declare const process: { env: { NODE_ENV?: string } };
130
+
131
+ const __DEV__ = process.env.NODE_ENV !== 'production';
132
+
133
+ const trackedNodes: Set<WeakRef<AnyNode>> | null = __DEV__ ? new Set() : null;
134
+
135
+ // Without this, `trackedNodes` grows by one WeakRef per signal/effect ever
136
+ // created and only sheds dead entries when the inspector hook is manually
137
+ // invoked (`__purity_inspect__.liveNodes()`) — which most apps never do. A
138
+ // long-running app with heavy each()/component churn would accumulate dead
139
+ // WeakRef wrappers (and their Set slots) without bound even though the nodes
140
+ // themselves are collected. The FinalizationRegistry drops each WeakRef from
141
+ // the Set when its node is reclaimed, keeping the registry ~bounded to live
142
+ // nodes. Feature-detected so non-supporting runtimes fall back to the lazy
143
+ // prune in inspectorLiveNodes().
144
+ const nodeFinalization: FinalizationRegistry<WeakRef<AnyNode>> | null =
145
+ __DEV__ && typeof FinalizationRegistry !== 'undefined'
146
+ ? new FinalizationRegistry<WeakRef<AnyNode>>((ref) => {
147
+ trackedNodes?.delete(ref);
148
+ })
149
+ : null;
150
+
151
+ function trackNode(node: AnyNode): void {
152
+ if (trackedNodes === null) return;
153
+ const ref = new WeakRef(node);
154
+ trackedNodes.add(ref);
155
+ nodeFinalization?.register(node, ref);
156
+ }
157
+
158
+ // ---------------------------------------------------------------------------
159
+ // read / write / track
160
+ // ---------------------------------------------------------------------------
161
+
162
+ function readState<T>(node: StateNode<T>): T {
163
+ if (activeListener !== null && !activeListener.disposed) track(node);
164
+ return node.value;
165
+ }
166
+
167
+ function readNode<T>(node: ComputedNode, trackReads = true): T {
168
+ if (node.status !== STATUS_CLEAN) updateValue(node);
169
+ // A disposed computed is a frozen snapshot, not a live reactive source: it
170
+ // can never become dirty again (its own sources were cut on dispose), so
171
+ // tracking it here would only create a dead observer edge.
172
+ if (trackReads && activeListener !== null && !activeListener.disposed && !node.disposed) {
173
+ track(node);
174
+ }
175
+ // Track before throwing so a consumer that catches the error still re-runs
176
+ // when a source of the failed compute changes.
177
+ if (node.error !== null) throw node.error.value;
178
+ return node.value as T;
179
+ }
180
+
181
+ function writeState<T>(node: StateNode<T>, value: T): void {
182
+ if (Object.is(node.value, value)) return;
183
+ node.value = value;
184
+ node.version++;
185
+ if (node.observers !== null) {
186
+ markDirty(node.observers);
187
+ if (batchDepth === 0) scheduleFlush();
188
+ }
189
+ }
190
+
191
+ function track(producer: AnyNode): void {
192
+ const consumer = activeListener!;
193
+ const idx = activeSourceIdx;
194
+ const sources = consumer.sources;
195
+
196
+ // Position-indexed reuse: if the slot at idx already points at the same
197
+ // producer, just refresh the version snapshot. Common case for stable
198
+ // dependency sets (most templates re-read the same signals each run).
199
+ if (sources !== null && idx < sources.length) {
200
+ const cur = sources[idx];
201
+ if (cur === producer) {
202
+ consumer.sourceMeta![idx * 2] = producer.version;
203
+ activeSourceIdx = idx + 1;
204
+ return;
205
+ }
206
+ // Slot occupied by a different producer — swap.
207
+ removeObserver(cur, consumer, idx);
208
+ sources[idx] = producer;
209
+ consumer.sourceMeta![idx * 2] = producer.version;
210
+ addObserver(producer, consumer, idx);
211
+ } else {
212
+ // Append fresh slot.
213
+ if (sources === null) {
214
+ consumer.sources = [producer];
215
+ consumer.sourceMeta = [producer.version, 0]; // slot overwritten below
216
+ } else {
217
+ sources.push(producer);
218
+ consumer.sourceMeta!.push(producer.version, 0);
219
+ }
220
+ addObserver(producer, consumer, idx);
221
+ }
222
+ activeSourceIdx = idx + 1;
223
+ }
224
+
225
+ function addObserver(producer: AnyNode, consumer: ComputedNode, sourceSlot: number): void {
226
+ const obs = producer.observers;
227
+ let pos: number;
228
+ if (obs === null) {
229
+ producer.observers = [consumer];
230
+ pos = 0;
231
+ } else {
232
+ pos = obs.length;
233
+ obs.push(consumer);
234
+ }
235
+ consumer.sourceMeta![sourceSlot * 2 + 1] = pos;
236
+ }
237
+
238
+ // `sourceSlot` is the consumer's position in its own sources[] for this producer.
239
+ // Knowing it lets us look up the consumer's slot in producer.observers in O(1)
240
+ // instead of scanning the (possibly thousands-long) observers list.
241
+ //
242
+ // Watchers may legitimately read the same signal twice in one fn body (e.g.
243
+ // `${() => item().a * item().b}`), in which case sources[] contains the same
244
+ // producer at multiple indices and producer.observers[] contains the consumer
245
+ // multiple times. We disambiguate the moved consumer's source slot by matching
246
+ // on (producer identity, currently-stored observer index) — that pair is
247
+ // unique within a single consumer.
248
+ function removeObserver(producer: AnyNode, consumer: ComputedNode, sourceSlot: number): void {
249
+ const obs = producer.observers;
250
+ if (obs === null) return;
251
+ const slot = consumer.sourceMeta![sourceSlot * 2 + 1];
252
+ // A removed source slot must no longer match a later swap's back-pointer.
253
+ // Repeated reads can be permuted by another consumer's removal; retaining
254
+ // the old index would then update an already detached slot instead of the
255
+ // moved live slot on this same consumer.
256
+ consumer.sourceMeta![sourceSlot * 2 + 1] = -1;
257
+ const last = obs.length - 1;
258
+ if (slot !== last) {
259
+ const moved = obs[last];
260
+ obs[slot] = moved;
261
+ const ms = moved.sources!;
262
+ const mslots = moved.sourceMeta!;
263
+ for (let k = 0; k < ms.length; k++) {
264
+ if (ms[k] === producer && mslots[k * 2 + 1] === last) {
265
+ mslots[k * 2 + 1] = slot;
266
+ break;
267
+ }
268
+ }
269
+ }
270
+ obs.pop();
271
+ if (obs.length === 0) producer.observers = null;
272
+ }
273
+
274
+ // Detach a node from every producer it currently observes.
275
+ function disconnectFromSources(node: ComputedNode): void {
276
+ const sources = node.sources;
277
+ if (sources === null) return;
278
+ for (let i = 0; i < sources.length; i++) removeObserver(sources[i], node, i);
279
+ node.sources = null;
280
+ node.sourceMeta = null;
281
+ }
282
+
283
+ // ---------------------------------------------------------------------------
284
+ // dirty propagation
285
+ // ---------------------------------------------------------------------------
286
+
287
+ // Computed-only graph traversal is installed when the first computed is
288
+ // created, so state-and-effect-only applications can omit it entirely.
289
+ let runDerived: ((node: ComputedNode) => void) | undefined;
290
+ let propagateComputedCheck: ((observers: ComputedNode[]) => void) | undefined;
291
+ let resolveComputedCheck: ((node: ComputedNode) => void) | undefined;
292
+ let settleComputedLoop: ((from: number) => void) | undefined;
293
+
294
+ function markDirty(observers: ComputedNode[]): void {
295
+ for (let i = 0; i < observers.length; i++) {
296
+ const o = observers[i];
297
+ if (o.status === STATUS_DIRTY) continue;
298
+ o.status = STATUS_DIRTY;
299
+ if (o.isEffect) pendingEffects.push(o);
300
+ if (o.observers !== null) propagateComputedCheck!(o.observers);
301
+ }
302
+ }
303
+
304
+ // Iterative (explicit stack) rather than recursive: a long-lived derived
305
+ // chain grown one link at a time never trips a stack-depth limit while
306
+ // building (each new link only touches its already-settled parent), but a
307
+ // later write to the root still has to mark every link in one synchronous
308
+ // pass. Pushing children in reverse index order preserves the same
309
+ // pre-order visitation (and thus pendingEffects push order) the old
310
+ // recursive version produced.
311
+ function markCheck(observers: ComputedNode[]): void {
312
+ const stack: ComputedNode[] = [];
313
+ for (let i = observers.length - 1; i >= 0; i--) stack.push(observers[i]);
314
+ while (stack.length > 0) {
315
+ const o = stack.pop()!;
316
+ if (o.status !== STATUS_CLEAN) continue;
317
+ o.status = STATUS_CHECK;
318
+ if (o.isEffect) pendingEffects.push(o);
319
+ const kids = o.observers;
320
+ if (kids !== null) {
321
+ for (let i = kids.length - 1; i >= 0; i--) stack.push(kids[i]);
322
+ }
323
+ }
324
+ }
325
+
326
+ // ---------------------------------------------------------------------------
327
+ // updateValue — pull a node back to CLEAN
328
+ // ---------------------------------------------------------------------------
329
+
330
+ interface CheckFrame {
331
+ node: ComputedNode;
332
+ sources: AnyNode[];
333
+ versions: number[];
334
+ idx: number;
335
+ }
336
+
337
+ // Iterative (explicit heap-allocated stack of frames) equivalent of the
338
+ // natural recursive formulation: to resolve a CHECK node, first resolve each
339
+ // CHECK/DIRTY source (depth-first), then compare versions and either settle
340
+ // CLEAN or recompute. A long incrementally-built chain can be arbitrarily
341
+ // deep — see the sibling comment on markCheck — so this walk must not grow
342
+ // the JS call stack by one frame per link. Behavior (including the
343
+ // first-changed-source short-circuit) is unchanged; only the mechanism is.
344
+ function updateValue(startNode: ComputedNode): void {
345
+ if (startNode.disposed) {
346
+ startNode.status = STATUS_CLEAN;
347
+ return;
348
+ }
349
+ if (startNode.status !== STATUS_CHECK) {
350
+ runComputed(startNode);
351
+ return;
352
+ }
353
+
354
+ resolveComputedCheck!(startNode);
355
+ }
356
+
357
+ function resolveCheck(startNode: ComputedNode): void {
358
+ const stack: CheckFrame[] = [
359
+ {
360
+ node: startNode,
361
+ sources: startNode.sources ?? [],
362
+ versions: startNode.sourceMeta ?? [],
363
+ idx: 0,
364
+ },
365
+ ];
366
+ // Nodes with a frame on the stack. A self-referential computed lists itself
367
+ // as a source; descending into it again would push frames forever. Created
368
+ // lazily at the first descent, when the stack holds only startNode.
369
+ let onStack: Set<ComputedNode> | null = null;
370
+
371
+ while (stack.length > 0) {
372
+ const frame = stack[stack.length - 1];
373
+ const { node, sources, versions } = frame;
374
+ let descended = false;
375
+
376
+ while (frame.idx < sources.length) {
377
+ const src = sources[frame.idx];
378
+ if (src.fn !== null) {
379
+ const sc = src as ComputedNode;
380
+ if (sc.disposed) {
381
+ sc.status = STATUS_CLEAN;
382
+ } else if (sc.status === STATUS_CHECK) {
383
+ // A source already being resolved (a cycle) cannot be resolved again;
384
+ // compare its version as it stands instead of descending.
385
+ const onPath = onStack !== null ? onStack.has(sc) : sc === node;
386
+ if (!onPath) {
387
+ // Resolve sc before we can know whether it actually changed —
388
+ // push a frame for it instead of recursing into updateValue(sc).
389
+ (onStack ??= new Set(stack.map((f) => f.node))).add(sc);
390
+ stack.push({
391
+ node: sc,
392
+ sources: sc.sources ?? [],
393
+ versions: sc.sourceMeta ?? [],
394
+ idx: 0,
395
+ });
396
+ descended = true;
397
+ break;
398
+ }
399
+ } else if (sc.status !== STATUS_CLEAN) {
400
+ runComputed(sc);
401
+ }
402
+ }
403
+ if (src.version !== versions[frame.idx * 2]) {
404
+ node.status = STATUS_DIRTY;
405
+ break;
406
+ }
407
+ frame.idx++;
408
+ }
409
+
410
+ if (descended) continue;
411
+
412
+ if (node.status === STATUS_CHECK) {
413
+ node.status = STATUS_CLEAN;
414
+ } else {
415
+ runComputed(node);
416
+ }
417
+ onStack?.delete(node);
418
+ stack.pop();
419
+ }
420
+ }
421
+
422
+ /** Run fn without subscribing the active listener to what it reads. @internal */
423
+ export function untracked<T>(fn: () => T): T {
424
+ const prevListener = activeListener;
425
+ activeListener = null;
426
+ try {
427
+ return fn();
428
+ } finally {
429
+ activeListener = prevListener;
430
+ }
431
+ }
432
+
433
+ /** Run and clear a scope's disposers; one throwing disposer doesn't stop the rest. @internal */
434
+ export function disposeScope(scope: Scope): void {
435
+ const disposers = scope.disposers;
436
+ if (!disposers) return;
437
+ scope.disposers = null;
438
+ // Cleanup reads must not subscribe whatever is running the disposal (an
439
+ // effect re-running, a match() switching cases, a list removing rows).
440
+ const prevListener = activeListener;
441
+ activeListener = null;
442
+ try {
443
+ for (let i = 0; i < disposers.length; i++) {
444
+ try {
445
+ disposers[i]();
446
+ } catch (err) {
447
+ console.error('[Purity] Error during disposal:', err);
448
+ }
449
+ }
450
+ } finally {
451
+ activeListener = prevListener;
452
+ }
453
+ }
454
+
455
+ function runComputedValue(node: ComputedNode): void {
456
+ // Release the previous run before fn re-evaluates; the user's cleanup
457
+ // closure still sees the values from the run that produced it.
458
+ if (node.disposers !== null) disposeScope(node);
459
+ if (node.disposed) {
460
+ node.status = STATUS_CLEAN;
461
+ return;
462
+ }
463
+
464
+ const prevListener = activeListener;
465
+ const prevIdx = activeSourceIdx;
466
+
467
+ let nextValue: unknown;
468
+ // Re-run-to-fixpoint loop. For a pure compute this runs exactly once.
469
+ //
470
+ // A compute that reads a source S and then writes S in the SAME fn body
471
+ // demotes itself: the write's markDirty() flips this node's status to DIRTY
472
+ // mid-run (the node is an observer of S). The old code then force-settled
473
+ // CLEAN, stranding the node with a source version snapshot that no longer
474
+ // matches S — permanently un-reactive (it never re-ran on a later pull, and
475
+ // returned a stale value). Instead we settle CLEAN optimistically before
476
+ // fn(), and if the body demoted us back to DIRTY we re-run to converge on a
477
+ // fixed point so the final snapshot is consistent with the value. Capped at
478
+ // MAX_EFFECT_DEPTH to bound a body that writes a strictly-changing source on
479
+ // every pass (a user bug — computes are meant to be pure).
480
+ let runGuard = 0;
481
+ // A compute whose fn throws caches the error (see ComputedNode.error) and
482
+ // settles CLEAN so it is not retried until a source changes.
483
+ let thrown = false;
484
+ let thrownValue: unknown;
485
+ // Suspend the cached error for the attempt: a self-read inside fn() sees the
486
+ // previous value (as it did before errors were cached) instead of rethrowing
487
+ // the old error, so a self-referential compute can recover. The outcome is
488
+ // reinstated below only if this attempt throws again.
489
+ const prevError = node.error;
490
+ node.error = null;
491
+ for (;;) {
492
+ activeListener = node;
493
+ activeSourceIdx = 0;
494
+ // Optimistically CLEAN before running. A re-entrant self-write's
495
+ // markDirty() then observes us as CLEAN and demotes us to DIRTY, which we
496
+ // detect after fn() returns.
497
+ node.status = STATUS_CLEAN;
498
+
499
+ try {
500
+ nextValue = node.fn();
501
+ thrown = false;
502
+ } catch (e) {
503
+ thrown = true;
504
+ thrownValue = e;
505
+ } finally {
506
+ // Truncate stale source slots. Anything past activeSourceIdx is no
507
+ // longer read by this fn — drop the producer→consumer link.
508
+ const consumed = activeSourceIdx;
509
+ const sources = node.sources;
510
+ if (sources !== null && sources.length > consumed) {
511
+ for (let i = consumed; i < sources.length; i++) {
512
+ removeObserver(sources[i], node, i);
513
+ }
514
+ sources.length = consumed;
515
+ node.sourceMeta!.length = consumed * 2;
516
+ }
517
+ activeListener = prevListener;
518
+ activeSourceIdx = prevIdx;
519
+ }
520
+
521
+ if (node.status === STATUS_CLEAN) break;
522
+ if (++runGuard > MAX_EFFECT_DEPTH) {
523
+ // Body keeps invalidating itself. Settle CLEAN to stop the loop and
524
+ // surface the misuse rather than hang.
525
+ node.status = STATUS_CLEAN;
526
+ console.error(
527
+ '[Purity] compute() did not stabilise.' +
528
+ (__DEV__ ? ' Its body keeps writing a source it reads; computes must be pure.' : ''),
529
+ );
530
+ break;
531
+ }
532
+ }
533
+
534
+ let changed: boolean;
535
+ if (thrown) {
536
+ changed = prevError === null || !Object.is(prevError.value, thrownValue);
537
+ node.error = changed ? { value: thrownValue } : prevError;
538
+ } else {
539
+ changed = prevError !== null || !Object.is(node.value, nextValue);
540
+ node.value = nextValue;
541
+ }
542
+ if (changed) {
543
+ node.version++;
544
+ // Direct observers reading us through the CHECK→CLEAN fast path were
545
+ // optimistic; force them DIRTY now that we know our value moved.
546
+ if (node.observers !== null) {
547
+ const obs = node.observers;
548
+ for (let i = 0; i < obs.length; i++) {
549
+ if (obs[i].status === STATUS_CHECK) obs[i].status = STATUS_DIRTY;
550
+ }
551
+ }
552
+ }
553
+ }
554
+
555
+ // ---------------------------------------------------------------------------
556
+ // Scheduler
557
+ // ---------------------------------------------------------------------------
558
+
559
+ function runComputed(node: ComputedNode): void {
560
+ if (node.isEffect) runEffect(node);
561
+ else runDerived!(node);
562
+ }
563
+
564
+ // Effects never expose a value or act as a producer, so they do not need the
565
+ // computed evaluator's value/error cache or fixpoint loop. Their cleanup and
566
+ // ownership behavior must match that evaluator's effect path.
567
+ function runEffect(node: ComputedNode): void {
568
+ if (node.disposers !== null) disposeScope(node);
569
+ if (node.disposed) {
570
+ node.status = STATUS_CLEAN;
571
+ return;
572
+ }
573
+ if (++effectDepth > MAX_EFFECT_DEPTH) {
574
+ effectDepth--;
575
+ throw new Error(
576
+ '[Purity] Maximum effect depth exceeded.' +
577
+ (__DEV__ ? ' A watch/effect callback is likely modifying the signal it depends on.' : ''),
578
+ );
579
+ }
580
+ const prevListener = activeListener;
581
+ const prevIdx = activeSourceIdx;
582
+ activeListener = node;
583
+ activeSourceIdx = 0;
584
+ node.status = STATUS_CLEAN;
585
+ pushContext(node);
586
+ let nextValue: unknown;
587
+ try {
588
+ nextValue = node.fn();
589
+ } finally {
590
+ popContext();
591
+ const consumed = activeSourceIdx;
592
+ const sources = node.sources;
593
+ if (sources !== null && sources.length > consumed) {
594
+ for (let i = consumed; i < sources.length; i++) removeObserver(sources[i], node, i);
595
+ sources.length = consumed;
596
+ node.sourceMeta!.length = consumed * 2;
597
+ }
598
+ activeListener = prevListener;
599
+ activeSourceIdx = prevIdx;
600
+ effectDepth--;
601
+ }
602
+ if (typeof nextValue === 'function') (node.disposers ??= []).push(nextValue as () => void);
603
+ if (node.disposed) disposeScope(node);
604
+ node.status = STATUS_CLEAN;
605
+ }
606
+
607
+ function scheduleFlush(): void {
608
+ if (microtaskScheduled) return;
609
+ microtaskScheduled = true;
610
+ queueMicrotask(flush);
611
+ }
612
+
613
+ function flush(): void {
614
+ microtaskScheduled = false;
615
+ // Process effects FIFO. New effects pushed during flushing are picked up
616
+ // in subsequent loop iterations of this same flush.
617
+ //
618
+ // Isolate each effect's throw: a user watch/effect callback that throws
619
+ // would otherwise (a) skip every sibling queued after it — their views
620
+ // stay stale until some unrelated write retriggers a flush — and (b)
621
+ // skip `pendingEffects.length = 0` below, leaving the queue polluted
622
+ // with stale entries the next flush would re-run. Catch + console.error
623
+ // reports unowned/unhandled failures, while component-owned effects use
624
+ // the existing onError chain. The outer try/finally guarantees the queue
625
+ // is cleared regardless.
626
+ const id = ++flushSeq;
627
+ try {
628
+ let i = 0;
629
+ while (i < pendingEffects.length) {
630
+ const e = pendingEffects[i++];
631
+ if (e.disposed || e.status === STATUS_CLEAN) continue;
632
+ if (e.flushId !== id) {
633
+ e.flushId = id;
634
+ e.flushRuns = 0;
635
+ }
636
+ if (++e.flushRuns > MAX_EFFECT_REPEATS) {
637
+ console.error(
638
+ '[Purity] effect feedback loop detected.' +
639
+ (__DEV__
640
+ ? ` An effect re-ran more than ${MAX_EFFECT_REPEATS} times in one flush: ` +
641
+ 'effects write signals that other effects read in a cycle. ' +
642
+ 'The remaining queued effects did not run.'
643
+ : ''),
644
+ );
645
+ settleAfterLoop(i - 1);
646
+ break;
647
+ }
648
+ // A callback can stop its own effect, which releases node.component.
649
+ // Retain the owner just for this attempt, and re-check its lifetime
650
+ // after releasing the failed run's work below.
651
+ const owner = e.component;
652
+ try {
653
+ updateValue(e);
654
+ } catch (err) {
655
+ // runComputed left the node DIRTY (it sets CLEAN only on the
656
+ // success path). Without resetting here, markDirty's
657
+ // already-DIRTY short-circuit would skip the effect on every
658
+ // subsequent write — the watcher would silently never run
659
+ // again. CLEAN restores the normal CLEAN→DIRTY transition that
660
+ // the next markDirty needs to re-queue. Release what the failed run
661
+ // created before throwing; no later run is guaranteed to.
662
+ e.status = STATUS_CLEAN;
663
+ disposeScope(e);
664
+ try {
665
+ if (!owner || owner._isDestroyed) throw err;
666
+ const prevListener = activeListener;
667
+ activeListener = null;
668
+ try {
669
+ owner._handleError(err);
670
+ } finally {
671
+ activeListener = prevListener;
672
+ }
673
+ } catch (unhandled) {
674
+ console.error('[Purity] watch/effect threw:', unhandled);
675
+ }
676
+ }
677
+ }
678
+ } finally {
679
+ pendingEffects.length = 0;
680
+ }
681
+ }
682
+
683
+ // Called when flush() cuts a feedback loop. Drops the queued effects from
684
+ // `from` onward and makes their upstream computeds re-checkable. markDirty
685
+ // skips nodes already DIRTY, so a computed left DIRTY would swallow the next
686
+ // write to its source and its watcher would stay stale for good. Demoting the
687
+ // DIRTY/CHECK ancestors to CHECK (not CLEAN) keeps them correct: a read
688
+ // re-verifies their sources, and a write re-invalidates them.
689
+ function settleAfterLoop(from: number): void {
690
+ if (settleComputedLoop) {
691
+ settleComputedLoop(from);
692
+ return;
693
+ }
694
+ for (let j = from; j < pendingEffects.length; j++) pendingEffects[j].status = STATUS_CLEAN;
695
+ }
696
+
697
+ function settleLoopWithComputeds(from: number): void {
698
+ const stack: ComputedNode[] = [];
699
+ for (let j = from; j < pendingEffects.length; j++) {
700
+ const e = pendingEffects[j];
701
+ e.status = STATUS_CLEAN;
702
+ if (e.sources !== null) {
703
+ for (const src of e.sources) if (src.fn !== null) stack.push(src);
704
+ }
705
+ }
706
+ const seen = new Set<ComputedNode>();
707
+ while (stack.length > 0) {
708
+ const c = stack.pop()!;
709
+ if (c.status === STATUS_CLEAN || seen.has(c)) continue;
710
+ seen.add(c);
711
+ c.status = STATUS_CHECK;
712
+ if (c.sources !== null) {
713
+ for (const src of c.sources) if (src.fn !== null) stack.push(src);
714
+ }
715
+ }
716
+ }
717
+
718
+ // ---------------------------------------------------------------------------
719
+ // Public types & API
720
+ // ---------------------------------------------------------------------------
721
+
722
+ /**
723
+ * Reactive state accessor. Call to read, call with value to write.
724
+ *
725
+ * @example
726
+ * ```ts
727
+ * const count = state(0);
728
+ * count() // read → 0
729
+ * count(5) // write → 5
730
+ * count(v => v+1) // update → 6
731
+ * count.peek() // read without tracking
732
+ * ```
733
+ */
734
+ export interface StateAccessor<T> {
735
+ /** Read the current value (tracked by watch/compute). */
736
+ (): T;
737
+ /** Set a new value. */
738
+ (value: T): T;
739
+ /** Update via callback — receives current value, returns new value. */
740
+ (updater: (current: T) => T): T;
741
+ /** Read the current value (tracked). */
742
+ get(): T;
743
+ /** Set a new value. */
744
+ set(value: T): void;
745
+ /** Read without tracking — won't trigger watch/compute. */
746
+ peek(): T;
747
+ }
748
+
749
+ /**
750
+ * Reactive computed accessor. Read-only derived value.
751
+ *
752
+ * @example
753
+ * ```ts
754
+ * const doubled = compute(() => count() * 2);
755
+ * doubled() // read derived value
756
+ * doubled.peek() // read without tracking
757
+ * ```
758
+ */
759
+ export interface ComputedAccessor<T> {
760
+ /** Read the derived value (tracked). */
761
+ (): T;
762
+ /** Read the derived value (tracked). */
763
+ get(): T;
764
+ /** Read without tracking. */
765
+ peek(): T;
766
+ }
767
+
768
+ /** Cleanup function returned by watch(). Call to stop watching. */
769
+ export type Dispose = () => void;
770
+
771
+ // ---------------------------------------------------------------------------
772
+ // state(initial)
773
+ // ---------------------------------------------------------------------------
774
+
775
+ /**
776
+ * Create reactive state. Returns an accessor function.
777
+ *
778
+ * @example
779
+ * ```ts
780
+ * const count = state(0);
781
+ * count() // read → 0
782
+ * count(5) // write → 5
783
+ * count(v => v + 1) // update with callback → 6
784
+ *
785
+ * // In templates — wrap in arrow for reactivity:
786
+ * html`<p>${() => count()}</p>`
787
+ *
788
+ * // Two-way binding on inputs:
789
+ * html`<input ::value=${count} />`
790
+ * ```
791
+ */
792
+ export function state<T>(initial: T): StateAccessor<T> {
793
+ const node: StateNode<T> = {
794
+ fn: null,
795
+ value: initial,
796
+ version: 0,
797
+ observers: null,
798
+ };
799
+ if (__DEV__) trackNode(node);
800
+
801
+ const accessor = function (value?: T | ((current: T) => T)): T {
802
+ if (arguments.length === 0) return readState(node);
803
+ const next = typeof value === 'function' ? (value as (current: T) => T)(node.value) : value;
804
+ writeState(node, next as T);
805
+ return next as T;
806
+ } as StateAccessor<T>;
807
+
808
+ // Avoid `bind()` and per-call closures: we can attach the property API
809
+ // directly because every call routes through `node` already.
810
+ (accessor as unknown as { get: () => T }).get = () => readState(node);
811
+ (accessor as unknown as { set: (v: T) => void }).set = (v: T) => {
812
+ writeState(node, v);
813
+ };
814
+ (accessor as unknown as { peek: () => T }).peek = () => node.value;
815
+
816
+ return accessor;
817
+ }
818
+
819
+ // ---------------------------------------------------------------------------
820
+ // compute(fn)
821
+ // ---------------------------------------------------------------------------
822
+
823
+ /**
824
+ * Create a computed (derived) value. Re-evaluates when dependencies change.
825
+ *
826
+ * @example
827
+ * ```ts
828
+ * const count = state(0);
829
+ * const doubled = compute(() => count() * 2);
830
+ * doubled() // → 0
831
+ * count(5);
832
+ * doubled() // → 10
833
+ *
834
+ * // In templates:
835
+ * html`<p>${() => doubled()}</p>`
836
+ * ```
837
+ */
838
+ function createComputedNode(fn: () => unknown, isEffect: boolean): ComputedNode {
839
+ const node: ComputedNode = {
840
+ fn,
841
+ value: undefined,
842
+ version: 0,
843
+ status: STATUS_DIRTY,
844
+ sources: null,
845
+ sourceMeta: null,
846
+ observers: null,
847
+ disposers: null,
848
+ component: isEffect ? getCurrentComponent() : null,
849
+ isEffect,
850
+ disposed: false,
851
+ error: null,
852
+ flushId: 0,
853
+ flushRuns: 0,
854
+ };
855
+ if (__DEV__) trackNode(node);
856
+ return node;
857
+ }
858
+
859
+ export function compute<T>(fn: () => T): ComputedAccessor<T> {
860
+ runDerived = runComputedValue;
861
+ propagateComputedCheck = markCheck;
862
+ resolveComputedCheck = resolveCheck;
863
+ settleComputedLoop = settleLoopWithComputeds;
864
+ const node = createComputedNode(fn as () => unknown, false);
865
+ // Register with the current scope so a computed created inside a branch
866
+ // that unmounts is detached from its sources (no dangling observer links).
867
+ const dispose = (): void => {
868
+ if (node.disposed) return;
869
+ node.disposed = true;
870
+ disconnectFromSources(node);
871
+ };
872
+ const ctx = getCurrentContext();
873
+ if (ctx) (ctx.disposers ??= []).push(dispose);
874
+
875
+ const accessor = (() => readNode<T>(node)) as ComputedAccessor<T>;
876
+ (accessor as unknown as { get: () => T }).get = accessor;
877
+ (accessor as unknown as { peek: () => T }).peek = () => readNode<T>(node, false);
878
+
879
+ return accessor;
880
+ }
881
+
882
+ // ---------------------------------------------------------------------------
883
+ // Internal effect runner
884
+ // ---------------------------------------------------------------------------
885
+
886
+ /** @internal Run a single auto-tracked effect for compiled templates. */
887
+ export function watchEffectInternal(fn: () => undefined | Dispose): Dispose {
888
+ const node = createComputedNode(fn as () => unknown, true);
889
+
890
+ // Effects run their initial body eagerly (synchronous) — the templates
891
+ // and tests rely on this to set up DOM bindings before the user code
892
+ // continues.
893
+ // A throwing initial run never returns a disposer to the caller, so drop the
894
+ // sources it managed to read before the throw; otherwise the unreachable
895
+ // effect stays subscribed and re-runs on later writes.
896
+ try {
897
+ updateValue(node);
898
+ } catch (e) {
899
+ node.disposed = true;
900
+ disposeScope(node);
901
+ disconnectFromSources(node);
902
+ throw e;
903
+ }
904
+
905
+ const dispose = (): void => {
906
+ if (node.disposed) return;
907
+ node.disposed = true;
908
+ disposeScope(node);
909
+ disconnectFromSources(node);
910
+ // A retained stop handle must not keep an unmounted component alive.
911
+ node.component = null;
912
+ };
913
+
914
+ // Auto-register with the current component/render context so reactive
915
+ // bindings created inside a template (or inside an each() entry, a
916
+ // component, etc.) are torn down when that scope unmounts.
917
+ const ctx = getCurrentContext();
918
+ if (ctx) (ctx.disposers ??= []).push(dispose);
919
+
920
+ return dispose;
921
+ }
922
+
923
+ // ---------------------------------------------------------------------------
924
+ // watch — unified reactive watcher
925
+ // ---------------------------------------------------------------------------
926
+
927
+ export type WatchSource<T> = StateAccessor<T> | ComputedAccessor<T> | (() => T);
928
+
929
+ type InferSource<S> = S extends WatchSource<infer T> ? T : never;
930
+
931
+ type InferSources<S extends readonly WatchSource<any>[]> = {
932
+ [K in keyof S]: InferSource<S[K]>;
933
+ };
934
+
935
+ /**
936
+ * Watch reactive values and run side effects.
937
+ *
938
+ * **Auto-track** — runs immediately, re-runs when any accessed signal changes:
939
+ * ```ts
940
+ * watch(() => console.log(count()));
941
+ * ```
942
+ *
943
+ * **Explicit source** — runs callback when source changes (skips initial):
944
+ * ```ts
945
+ * watch(count, (newVal, oldVal) => {
946
+ * console.log(`changed from ${oldVal} to ${newVal}`);
947
+ * });
948
+ * ```
949
+ *
950
+ * **Multiple sources** — watches a tuple of signals:
951
+ * ```ts
952
+ * watch([count, name], ([c, n], [oldC, oldN]) => {
953
+ * console.log(c, n);
954
+ * });
955
+ * ```
956
+ *
957
+ * **Cleanup** — return a function from the callback to clean up before re-run:
958
+ * ```ts
959
+ * const stop = watch(() => {
960
+ * const id = setInterval(() => tick(), 1000);
961
+ * return () => clearInterval(id); // cleanup
962
+ * });
963
+ * stop(); // dispose the watcher
964
+ * ```
965
+ *
966
+ * @returns Dispose function — call to stop watching.
967
+ */
968
+ export function watch(fn: () => undefined | Dispose): Dispose;
969
+ export function watch<T>(
970
+ source: WatchSource<T>,
971
+ cb: (value: T, oldValue: T) => undefined | Dispose,
972
+ ): Dispose;
973
+ export function watch<const S extends readonly WatchSource<any>[]>(
974
+ sources: [...S],
975
+ cb: (values: InferSources<S>, oldValues: InferSources<S>) => undefined | Dispose,
976
+ ): Dispose;
977
+
978
+ export function watch(
979
+ sourceOrFn: WatchSource<any> | WatchSource<any>[] | (() => undefined | Dispose),
980
+ cb?: (value: any, oldValue: any) => undefined | Dispose,
981
+ ): Dispose {
982
+ if (!cb) {
983
+ return watchEffectInternal(sourceOrFn as () => undefined | Dispose);
984
+ }
985
+
986
+ const isArray = Array.isArray(sourceOrFn);
987
+ const sources: WatchSource<any>[] = isArray ? sourceOrFn : [sourceOrFn as WatchSource<any>];
988
+ const len = sources.length;
989
+
990
+ let oldValues = new Array(len);
991
+ let newValues = new Array(len);
992
+
993
+ // The eager first effect run samples and subscribes to every source; the
994
+ // buffer swap below keeps those values as the next callback's oldValues.
995
+ // Do not read source getters here as well: that evaluates user code twice.
996
+ let first = true;
997
+
998
+ return watchEffectInternal(() => {
999
+ // A callback may retain tuple arguments even if it throws. Recycle only
1000
+ // the single-source buffers; each tuple run owns its own snapshot array.
1001
+ if (isArray) newValues = [];
1002
+ for (let i = 0; i < len; i++) newValues[i] = sources[i]();
1003
+
1004
+ // The first run only records the values; cb fires from the next change.
1005
+ // Explicit sources alone control re-runs; callback reads are not dependencies.
1006
+ const result = first
1007
+ ? undefined
1008
+ : untracked(() => {
1009
+ return isArray ? cb(newValues, oldValues) : cb(newValues[0], oldValues[0]);
1010
+ });
1011
+ first = false;
1012
+
1013
+ const tmp = oldValues;
1014
+ oldValues = newValues;
1015
+ newValues = tmp;
1016
+
1017
+ return result;
1018
+ });
1019
+ }
1020
+
1021
+ // ---------------------------------------------------------------------------
1022
+ // batch(fn)
1023
+ // ---------------------------------------------------------------------------
1024
+
1025
+ /**
1026
+ * Batch multiple signal writes into a single flush. Effects only run once
1027
+ * after the batch.
1028
+ *
1029
+ * @example
1030
+ * ```ts
1031
+ * batch(() => {
1032
+ * firstName('Jane');
1033
+ * lastName('Doe');
1034
+ * age(30);
1035
+ * });
1036
+ * // Effects that depend on these signals fire once, not three times.
1037
+ * ```
1038
+ */
1039
+ export function batch(fn: () => void): void {
1040
+ batchDepth++;
1041
+ try {
1042
+ fn();
1043
+ } finally {
1044
+ batchDepth--;
1045
+ if (batchDepth === 0 && pendingEffects.length > 0) scheduleFlush();
1046
+ }
1047
+ }
1048
+
1049
+ // ---------------------------------------------------------------------------
1050
+ // __purity_inspect__ — debugging hook on globalThis
1051
+ //
1052
+ // See `docs/debugging.md` for usage and ADR-0002 for the rationale.
1053
+ // ---------------------------------------------------------------------------
1054
+
1055
+ type InspectorKind = 'state' | 'computed' | 'effect';
1056
+ type InspectorStatus = 'clean' | 'check' | 'dirty';
1057
+
1058
+ interface InspectorNode {
1059
+ kind: InspectorKind;
1060
+ version: number;
1061
+ status?: InspectorStatus;
1062
+ value: unknown;
1063
+ sources: InspectorNode[];
1064
+ observers: InspectorNode[];
1065
+ }
1066
+
1067
+ // Everything below is dev-only: see the __DEV__ comment above trackedNodes.
1068
+ // A production build that folds __DEV__ to false can drop this entire block
1069
+ // — the inspector hook, its node-graph conversion, and the labels array —
1070
+ // since nothing outside this block (or outside development tooling) calls
1071
+ // any of it.
1072
+ if (__DEV__) {
1073
+ const INSPECTOR_STATUS_LABELS: readonly InspectorStatus[] = ['clean', 'check', 'dirty'];
1074
+
1075
+ const inspectorKindOf = (n: AnyNode): InspectorKind => {
1076
+ if (n.fn === null) return 'state';
1077
+ return (n as ComputedNode).isEffect ? 'effect' : 'computed';
1078
+ };
1079
+
1080
+ const toInspectorNode = (n: AnyNode, seen: Map<AnyNode, InspectorNode>): InspectorNode => {
1081
+ const cached = seen.get(n);
1082
+ if (cached) return cached;
1083
+ const out: InspectorNode = {
1084
+ kind: inspectorKindOf(n),
1085
+ version: n.version,
1086
+ value: n.value,
1087
+ sources: [],
1088
+ observers: [],
1089
+ };
1090
+ if (n.fn !== null) out.status = INSPECTOR_STATUS_LABELS[(n as ComputedNode).status];
1091
+ seen.set(n, out);
1092
+ if (n.fn !== null) {
1093
+ const sources = (n as ComputedNode).sources;
1094
+ if (sources !== null) {
1095
+ for (let i = 0; i < sources.length; i++) {
1096
+ out.sources.push(toInspectorNode(sources[i], seen));
1097
+ }
1098
+ }
1099
+ }
1100
+ if (n.observers !== null) {
1101
+ for (let i = 0; i < n.observers.length; i++) {
1102
+ out.observers.push(toInspectorNode(n.observers[i], seen));
1103
+ }
1104
+ }
1105
+ return out;
1106
+ };
1107
+
1108
+ const inspectorLiveNodes = (): AnyNode[] => {
1109
+ const live: AnyNode[] = [];
1110
+ for (const ref of trackedNodes!) {
1111
+ const n = ref.deref();
1112
+ if (n) live.push(n);
1113
+ else trackedNodes!.delete(ref);
1114
+ }
1115
+ return live;
1116
+ };
1117
+
1118
+ // Install the hook eagerly at module load. The assignment is a side
1119
+ // effect on globalThis that bundlers must preserve. `globalThis` works
1120
+ // in workers, sandboxed iframes, jsdom, etc. — falls back to a no-op if
1121
+ // somehow absent.
1122
+ /* v8 ignore next 2 -- defensive; globalThis exists everywhere we run */
1123
+ const inspectorTarget: { __purity_inspect__?: unknown } | null =
1124
+ typeof globalThis !== 'undefined' ? (globalThis as { __purity_inspect__?: unknown }) : null;
1125
+ if (inspectorTarget !== null) {
1126
+ inspectorTarget.__purity_inspect__ = {
1127
+ version: 1 as const,
1128
+ nodes(): InspectorNode[] {
1129
+ const seen = new Map<AnyNode, InspectorNode>();
1130
+ return inspectorLiveNodes().map((n) => toInspectorNode(n, seen));
1131
+ },
1132
+ };
1133
+ }
1134
+ }