@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/LICENSE +201 -692
- package/README.md +65 -45
- package/jsr.json +3 -3
- package/package.json +34 -13
- package/src/bind-list.ts +16 -3
- package/src/collection.ts +11 -3
- package/src/reconcile-tree.ts +88 -11
- package/src/signal.ts +79 -33
- package/src/store.ts +14 -2
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
|
-
//
|
|
4
|
-
//
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|