@cplieger/reactive 1.2.4 → 1.3.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 CHANGED
@@ -1,7 +1,40 @@
1
1
  // Reactive signals: signal<T>, effect (with cleanup), batch, computed, untracked.
2
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.
3
+ // per-node version fast-skip, bitfield flags.
4
+ // Apache-2.0.
5
+ //
6
+ // Port provenance: graph/scheduler semantics verified against
7
+ // @preact/signals-core@1.14.4 (drift audit 2026-07-17). Deliberate deltas
8
+ // from upstream, pinned by tests: Object.is equality incl. NaN (upstream:
9
+ // `!==`); custom `equals` / `equals: false` options (upstream has none);
10
+ // isolate-and-continue effect error policy via setEffectErrorHandler
11
+ // (upstream disposes-and-rethrows); the cycle guard lives in endBatch's drain
12
+ // and un-notifies the chain it drops (upstream guards ONLY in the signal
13
+ // setter, evaluated before the write, so it never drops a chain); and the drain
14
+ // is re-entrant — `if (--batchDepth > 0) return` lets a write from an effect
15
+ // body start a fresh drain, where upstream's `if (batchDepth > 1) return` plus
16
+ // a post-drain `batchDepth--` keeps one drain per outermost batch.
17
+ //
18
+ // Upstream machinery NOT ported by design:
19
+ // - watched/unwatched hooks, createModel/action (1.13+);
20
+ // - the 1.14.x batch-snapshot A->B->A revert optimization — the
21
+ // version-monotonicity bug its first release carried
22
+ // (preactjs/signals#947) never existed here;
23
+ // - the `globalVersion` epoch fast-skip in refreshComputed. All three
24
+ // refresh call sites pre-check DIRTY, and because this port never
25
+ // unsubscribes a computed from its sources, notifyTargets always reaches
26
+ // every dependent computed — so DIRTY is a complete staleness signal.
27
+ // Upstream needs the epoch for computeds with no subscribers, which have
28
+ // no live edges. Do NOT try to revive it by deleting the DIRTY pre-checks:
29
+ // this refreshComputed has neither upstream's TRACKING shortcut nor its
30
+ // internal needsToRecompute gate, so an epoch miss recomputes
31
+ // unconditionally and six recompute-gating tests go red.
32
+ // - upstream's endBatch error capture and rethrow. Effect errors are
33
+ // isolated inside runEffect (see its contract below), so nothing can throw
34
+ // out of the drain and there was never anything to capture.
35
+ //
36
+ // Re-audit on upstream releases: diff src/index.ts against this file's
37
+ // mechanisms and harvest engine-internal fixes (see CONTRIBUTING.md).
5
38
 
6
39
  const SIGNAL_BRAND = Symbol("signal");
7
40
  const COMPUTED_BRAND = Symbol("computed");
@@ -46,7 +79,6 @@ interface ComputedNode extends SourceNode, TargetNode {
46
79
  _isComputed: true;
47
80
  _fn: () => unknown;
48
81
  _equals: ((a: unknown, b: unknown) => boolean) | undefined;
49
- _globalVersion: number;
50
82
  }
51
83
 
52
84
  interface EffectNode {
@@ -63,7 +95,6 @@ let evalContext: TargetNode | undefined;
63
95
  let batchDepth = 0;
64
96
  let batchedEffect: EffectNode | undefined;
65
97
  let batchIteration = 0;
66
- let globalVersion = 0;
67
98
 
68
99
  // Max effect-scheduling passes within one endBatch before assuming a cyclic
69
100
  // effect-write loop and bailing with "Cycle detected".
@@ -212,7 +243,22 @@ function notifyTargets(source: SourceNode): void {
212
243
  const target = node._target;
213
244
  if (target._isComputed) {
214
245
  // It's a ComputedNode
215
- if (!(target._flags & (NOTIFIED | RUNNING))) {
246
+ if (target._flags & RUNNING) {
247
+ // A source written while this computed is mid-evaluation. Mark it stale
248
+ // so the next read re-runs it — every refresh call site pre-checks
249
+ // DIRTY, so without this the computed serves its pre-write value
250
+ // forever — but do NOT recurse: its own version has not moved yet, and
251
+ // recursing out of a RUNNING node is how a notify ping-pong is built.
252
+ //
253
+ // This repairs the PULL side only, deliberately. Two limits the next
254
+ // reader should not have to rediscover: (1) the computed's own
255
+ // subscribers are not woken here, so an effect that depends on it will
256
+ // not re-run until something else notifies it; (2) a computed that
257
+ // writes its own source unconditionally costs one extra recompute per
258
+ // read, and inside an effect it degrades into the documented
259
+ // MAX_BATCH_ITERATIONS "Cycle detected" path rather than hanging.
260
+ target._flags |= DIRTY;
261
+ } else if (!(target._flags & NOTIFIED)) {
216
262
  target._flags |= DIRTY | NOTIFIED;
217
263
  notifyTargets(target as unknown as SourceNode);
218
264
  target._flags &= ~NOTIFIED;
@@ -258,12 +304,6 @@ function refreshComputed(comp: ComputedNode): void {
258
304
  throw new Error("Cycle detected");
259
305
  }
260
306
 
261
- // Fast-path: nothing changed globally since last check
262
- if (comp._globalVersion === globalVersion && !(comp._flags & DIRTY)) {
263
- return;
264
- }
265
- comp._globalVersion = globalVersion;
266
-
267
307
  const prevContext = evalContext;
268
308
  evalContext = comp;
269
309
  const prevFlags = comp._flags;
@@ -333,14 +373,23 @@ function endBatch(): void {
333
373
  if (--batchDepth > 0) {
334
374
  return;
335
375
  }
336
- let error: unknown;
337
- let hasError = false;
338
376
  while (batchedEffect !== undefined) {
339
377
  let eff: EffectNode | undefined = batchedEffect;
340
378
  batchedEffect = undefined;
341
379
  batchIteration++;
342
380
  if (batchIteration > MAX_BATCH_ITERATIONS) {
343
381
  batchIteration = 0;
382
+ // Un-notify the chain we are dropping: notifyTargets skips a NOTIFIED
383
+ // effect, so an effect left NOTIFIED here could never be queued again —
384
+ // bailing would silently deafen every effect in flight, including
385
+ // bystanders that merely share a signal with the cycling one.
386
+ let drop: EffectNode | undefined = eff;
387
+ while (drop !== undefined) {
388
+ const nextDrop: EffectNode | undefined = drop._nextBatchedEffect;
389
+ drop._nextBatchedEffect = undefined;
390
+ drop._flags &= ~NOTIFIED;
391
+ drop = nextDrop;
392
+ }
344
393
  throw new Error("Cycle detected");
345
394
  }
346
395
  while (eff !== undefined) {
@@ -348,22 +397,12 @@ function endBatch(): void {
348
397
  eff._nextBatchedEffect = undefined;
349
398
  eff._flags &= ~NOTIFIED;
350
399
  if (!(eff._flags & DISPOSED) && needsToRecompute(eff)) {
351
- try {
352
- runEffect(eff);
353
- } catch (e) {
354
- if (!hasError) {
355
- error = e;
356
- hasError = true;
357
- }
358
- }
400
+ runEffect(eff);
359
401
  }
360
402
  eff = next;
361
403
  }
362
404
  }
363
405
  batchIteration = 0;
364
- if (hasError) {
365
- throw error;
366
- }
367
406
  }
368
407
 
369
408
  function runCleanup(eff: EffectNode): void {
@@ -382,6 +421,13 @@ function runCleanup(eff: EffectNode): void {
382
421
  }
383
422
  }
384
423
 
424
+ // Contract: runEffect NEVER throws, and both callers depend on it.
425
+ // Its body and its cleanup each route to safeCallHandler, which swallows even a
426
+ // throwing handler; everything else here is a pointer walk over a well-formed
427
+ // list. endBatch's drain and the effect() factory therefore call it bare. A
428
+ // throw escaping this function would abandon the rest of the queued chain
429
+ // mid-drain — the same shape of failure as the cycle bail's NOTIFIED leak. If
430
+ // you add a statement here that can throw, wrap it HERE, not at the call sites.
385
431
  function runEffect(eff: EffectNode): void {
386
432
  // Run cleanup first (untracked)
387
433
  runCleanup(eff);
@@ -474,7 +520,6 @@ export function signal<T>(initial: T, options?: SignalOptions<T>): Signal<T> {
474
520
  }
475
521
  node._value = v;
476
522
  node._version++;
477
- globalVersion++;
478
523
  startBatch();
479
524
  try {
480
525
  notifyTargets(node);
@@ -506,7 +551,6 @@ export function computed<T>(fn: () => T, options?: SignalOptions<T>): ReadonlySi
506
551
  _fn: fn,
507
552
  _equals:
508
553
  eq === false ? () => false : eq ? (a: unknown, b: unknown) => eq(a as T, b as T) : undefined,
509
- _globalVersion: -1,
510
554
  };
511
555
 
512
556
  const c = {
@@ -564,11 +608,7 @@ export function effect(fn: () => Cleanup): () => void {
564
608
  };
565
609
 
566
610
  // Run immediately
567
- try {
568
- runEffect(eff);
569
- } catch (e) {
570
- safeCallHandler(e);
571
- }
611
+ runEffect(eff);
572
612
 
573
613
  return () => {
574
614
  if (eff._flags & DISPOSED) {
@@ -608,13 +648,19 @@ export function untracked<T>(fn: () => T): T {
608
648
  }
609
649
  }
610
650
 
611
- /** Subscribe to a signal, calling cb on every change. Returns dispose function. */
651
+ /** Subscribe to a signal, calling cb on every change. Returns dispose function.
652
+ * The callback runs untracked: reading other signals inside it does not add
653
+ * them as dependencies of the subscription (matches upstream
654
+ * preactjs/signals#188 semantics). */
612
655
  export function subscribe<T>(
613
656
  sig: Signal<T> | ReadonlySignal<T>,
614
657
  cb: (value: T) => void,
615
658
  ): () => void {
616
659
  return effect(() => {
617
- cb(sig.value);
660
+ const value = sig.value;
661
+ untracked(() => {
662
+ cb(value);
663
+ });
618
664
  return undefined;
619
665
  });
620
666
  }
package/src/store.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  // interface MyMap { count: number; name: string }
12
12
  // const { get, set, subscribe, effect, computed, batch } = createStore<MyMap>();
13
13
 
14
- import { signal, effect, batch, type Signal, type Cleanup } from "./signal.js";
14
+ import { signal, effect, batch, untracked, type Signal, type Cleanup } from "./signal.js";
15
15
 
16
16
  /** A typed per-key reactive store with auto-tracked effects, computed, and batching. */
17
17
  export interface Store<M> {
@@ -19,6 +19,14 @@ export interface Store<M> {
19
19
  set<K extends keyof M & string>(key: K, value: M[K]): void;
20
20
  subscribe<K extends keyof M & string>(key: K, cb: (value: M[K]) => void): () => void;
21
21
  effect(fn: () => Cleanup): () => void;
22
+ /** Derive `outputKey` from other keys. NOT a lazy engine `computed`: this is
23
+ * an EAGER effect that re-runs `fn` on dependency change and WRITES the
24
+ * result to `outputKey`. Consequences of that shape: `fn` runs whether or
25
+ * not anyone reads `outputKey`; a throwing `fn` is isolated by the effect
26
+ * error handler (not cached and rethrown at the read site like an engine
27
+ * computed); `set(outputKey, …)` still works between recomputes; and a
28
+ * self-reading `fn` behaves as documented on the implementation below.
29
+ * Returns the effect's dispose function. */
22
30
  computed<K extends keyof M & string>(outputKey: K, fn: () => M[K]): () => void;
23
31
  batch(fn: () => void): void;
24
32
  }
@@ -57,7 +65,11 @@ export function createStore<M>(): Store<M> {
57
65
  primed = true;
58
66
  return;
59
67
  }
60
- cb(v);
68
+ // Untracked, mirroring the engine's `subscribe`: a callback that reads
69
+ // other keys must not become a dependency of this subscription.
70
+ untracked(() => {
71
+ cb(v);
72
+ });
61
73
  });
62
74
  }
63
75