@cplieger/reactive 1.2.0 → 1.2.2

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/README.md CHANGED
@@ -92,7 +92,7 @@ isComputed(doubled); // true
92
92
 
93
93
  `createStore` and `SignalMap` are thin facades over the one signal engine — there is no second reactivity implementation. `createStore` lazily backs each key with a signal; `SignalMap` is a registry of signals keyed by a runtime string id.
94
94
 
95
- - `createStore<M>(): Store<M>` — typed, fixed-key reactive store with `get`, `set`, `subscribe`, `effect`, `computed`, and `batch`. `subscribe` notifies on change only (not immediately on subscribe). A `computed` key whose fn reads its own output throws `Error("Cycle detected")` rather than looping.
95
+ - `createStore<M>(): Store<M>` — typed, fixed-key reactive store with `get`, `set`, `subscribe`, `effect`, `computed`, and `batch`. `subscribe` notifies on change only (not immediately on subscribe). A `computed` key whose fn reads its own output does not loop unbounded: a self-read that keeps yielding new values trips the engine's batch-iteration guard and surfaces `Error("Cycle detected")` through the effect error handler (effects isolate errors, so it is not rethrown to the caller), while a stable self-read settles via `Object.is` dedup.
96
96
  - `SignalMap<V>` — dynamic per-id signal registry: `get(id)`, `ensure(id, initial)`, `clear(id)`, `clearAll()`. For reactive state whose key set isn't known at the type level (per-message streaming text, per-row state, …); complements `createStore`'s fixed key set.
97
97
 
98
98
  ### Collections
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/reactive",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "exports": {
5
5
  ".": "./src/index.ts"
6
6
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/reactive",
3
- "version": "1.2.0",
3
+ "version": "1.2.2",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
@@ -14,12 +14,12 @@
14
14
  "devDependencies": {
15
15
  "@eslint/js": "10.0.1",
16
16
  "@vitest/coverage-v8": "4.1.9",
17
- "eslint": "10.5.0",
17
+ "eslint": "10.6.0",
18
18
  "fast-check": "4.8.0",
19
- "happy-dom": "20.10.3",
20
- "prettier": "3.8.4",
19
+ "happy-dom": "20.10.6",
20
+ "prettier": "3.9.0",
21
21
  "typescript": "6.0.3",
22
- "typescript-eslint": "8.61.1",
22
+ "typescript-eslint": "8.62.0",
23
23
  "vitest": "4.1.9"
24
24
  },
25
25
  "description": "Signals + DOM-reconciliation reactive core for TypeScript (zero dependencies)",
package/src/el.ts CHANGED
@@ -8,12 +8,7 @@ import { trackHandler } from "./reconcile-tree.js";
8
8
  /** A value for an element attribute/property/handler. Functions are treated as
9
9
  * event handlers when the key starts with `on`. */
10
10
  export type AttrValue =
11
- | string
12
- | number
13
- | boolean
14
- | null
15
- | undefined
16
- | ((...args: never[]) => unknown);
11
+ string | number | boolean | null | undefined | ((...args: never[]) => unknown);
17
12
 
18
13
  // Keys set as DOM *properties* (not attributes) because the attribute form
19
14
  // either doesn't reflect (value/checked) or is awkward (boolean presence).
@@ -51,12 +46,11 @@ export function el(
51
46
  }
52
47
  if (k === "className") {
53
48
  e.className = v as string;
54
- } else if (k.startsWith("on")) {
49
+ } else if (/^on/i.test(k)) {
55
50
  (e as unknown as Record<string, unknown>)[k] = v;
56
51
  trackHandler(e, k);
57
- } else if (BOOL_PROPS.has(k)) {
58
- (e as unknown as Record<string, unknown>)[k] = v;
59
- } else if (VALUE_PROPS.has(k)) {
52
+ } else if (BOOL_PROPS.has(k) || VALUE_PROPS.has(k)) {
53
+ // Both sets are assigned as DOM properties, not attributes.
60
54
  (e as unknown as Record<string, unknown>)[k] = v;
61
55
  } else {
62
56
  e.setAttribute(k, String(v));
@@ -24,10 +24,10 @@ export function patch(
24
24
  if (child == null) {
25
25
  continue;
26
26
  }
27
- if ((child as Node).nodeType === 11) {
28
- newChildren.push(...Array.from((child as Node).childNodes));
29
- } else if (typeof child === "string") {
27
+ if (typeof child === "string") {
30
28
  newChildren.push(document.createTextNode(child));
29
+ } else if (child.nodeType === 11) {
30
+ newChildren.push(...Array.from(child.childNodes));
31
31
  } else {
32
32
  newChildren.push(child);
33
33
  }
@@ -58,9 +58,15 @@ export function reconcileChildren(parent: Node, newChildren: Node[]): void {
58
58
  if (matched) {
59
59
  oldByKey.delete(newKey);
60
60
  } else if (!newKey) {
61
+ // Unkeyed new node: match the next UNKEYED old node by position, advancing
62
+ // past keyed old nodes (which are reserved for key-based matching) and any
63
+ // already-consumed slots. This mirrors the documented contract ("unkeyed
64
+ // nodes match by position, skipping keyed nodes") and React/Preact's
65
+ // keyed+positional reconciliation. Stopping at (and matching) a keyed old
66
+ // node would corrupt a node that key-matching also reuses in the same pass.
61
67
  while (oldIdx < oldChildren.length) {
62
68
  const oc = oldChildren[oldIdx];
63
- if (oc === undefined || nodeKey(oc)) {
69
+ if (oc !== undefined && !nodeKey(oc)) {
64
70
  break;
65
71
  }
66
72
  oldIdx++;
@@ -78,7 +84,17 @@ export function reconcileChildren(parent: Node, newChildren: Node[]): void {
78
84
  }
79
85
 
80
86
  if (!canPatch(matched, newChild)) {
81
- parent.replaceChild(newChild, matched);
87
+ // Insert the replacement at the target index i, then drop the mismatched
88
+ // old node — which may NOT be at index i. The positional scan lets an
89
+ // unkeyed new node match a positionally-later unkeyed old node while an
90
+ // unconsumed keyed old node sits ahead of it, so `matched` can be at a
91
+ // position > i. replaceChild would leave newChild at matched's position;
92
+ // a later insert then pushes it past newChildren.length and the trailing
93
+ // removal loop deletes it, dropping the new node and leaving the stale
94
+ // keyed node. The insert and patch branches already target index i.
95
+ const ref = parent.childNodes.item(i);
96
+ parent.insertBefore(newChild, ref);
97
+ parent.removeChild(matched);
82
98
  continue;
83
99
  }
84
100
 
package/src/reconcile.ts CHANGED
@@ -44,7 +44,12 @@ export function reconcile<T>(
44
44
  spec.update(el, item);
45
45
  }
46
46
  }
47
- parent.insertBefore(el, target);
47
+ // Skip the move when el is already at its target position: re-inserting a node
48
+ // that is already correctly placed still detaches+reattaches it in the DOM,
49
+ // which blurs a focused input / drops a text selection inside that row.
50
+ if (el.parentNode !== parent || el.nextSibling !== target) {
51
+ parent.insertBefore(el, target);
52
+ }
48
53
  target = el;
49
54
  }
50
55
 
package/src/signal.ts CHANGED
@@ -8,11 +8,9 @@ const COMPUTED_BRAND = Symbol("computed");
8
8
 
9
9
  // --- Bitfield flags ---
10
10
  const DIRTY = 1;
11
- const CHECK = 2;
12
11
  const RUNNING = 4;
13
12
  const DISPOSED = 8;
14
13
  const HAS_ERROR = 16;
15
- const TRACKING = 32;
16
14
  const NOTIFIED = 64;
17
15
 
18
16
  // --- Linked-list Node for source↔target edges ---
@@ -67,6 +65,10 @@ let batchedEffect: EffectNode | undefined;
67
65
  let batchIteration = 0;
68
66
  let globalVersion = 0;
69
67
 
68
+ // Max effect-scheduling passes within one endBatch before assuming a cyclic
69
+ // effect-write loop and bailing with "Cycle detected".
70
+ const MAX_BATCH_ITERATIONS = 100;
71
+
70
72
  // --- Effect error handler ---
71
73
  /** Cleanup function returned by an effect callback, called before re-execution or disposal. */
72
74
  export type Cleanup = undefined | (() => void);
@@ -235,7 +237,7 @@ function needsToRecompute(target: TargetNode): boolean {
235
237
  const source = node._source;
236
238
  if (source._isComputed) {
237
239
  const comp = source as unknown as ComputedNode;
238
- if (comp._flags & (DIRTY | CHECK)) {
240
+ if (comp._flags & DIRTY) {
239
241
  refreshComputed(comp);
240
242
  }
241
243
  if (comp._flags & HAS_ERROR) {
@@ -257,42 +259,15 @@ function refreshComputed(comp: ComputedNode): void {
257
259
  }
258
260
 
259
261
  // Fast-path: nothing changed globally since last check
260
- if (comp._globalVersion === globalVersion && !(comp._flags & (DIRTY | CHECK))) {
262
+ if (comp._globalVersion === globalVersion && !(comp._flags & DIRTY)) {
261
263
  return;
262
264
  }
263
265
  comp._globalVersion = globalVersion;
264
266
 
265
- // If not dirty but CHECK, verify sources first
266
- if (!(comp._flags & DIRTY) && comp._flags & CHECK) {
267
- let sn = comp._sources;
268
- while (sn !== undefined) {
269
- const src = sn._source;
270
- if (src._isComputed) {
271
- const srcComp = src as unknown as ComputedNode;
272
- if (srcComp._flags & (DIRTY | CHECK)) {
273
- refreshComputed(srcComp);
274
- }
275
- if (srcComp._flags & HAS_ERROR) {
276
- comp._flags |= DIRTY;
277
- break;
278
- }
279
- }
280
- if (src._version !== sn._version) {
281
- comp._flags |= DIRTY;
282
- break;
283
- }
284
- sn = sn._nextSource;
285
- }
286
- if (!(comp._flags & DIRTY)) {
287
- comp._flags &= ~CHECK;
288
- return;
289
- }
290
- }
291
-
292
267
  const prevContext = evalContext;
293
268
  evalContext = comp;
294
269
  const prevFlags = comp._flags;
295
- comp._flags = RUNNING | TRACKING;
270
+ comp._flags = RUNNING;
296
271
 
297
272
  prepareSources(comp);
298
273
 
@@ -304,7 +279,7 @@ function refreshComputed(comp: ComputedNode): void {
304
279
  // PATH A: fn() threw — cache the error
305
280
  fnThrew = true;
306
281
  comp._value = err;
307
- comp._flags = HAS_ERROR | TRACKING;
282
+ comp._flags = HAS_ERROR;
308
283
  comp._version++;
309
284
  // Keep old deps (version=-1 nodes) so computed retries when sources change.
310
285
  let sn = comp._sources;
@@ -318,7 +293,7 @@ function refreshComputed(comp: ComputedNode): void {
318
293
  evalContext = prevContext;
319
294
  cleanupSources(comp);
320
295
  if (!fnThrew) {
321
- comp._flags = (comp._flags & ~RUNNING) | TRACKING;
296
+ comp._flags = comp._flags & ~RUNNING;
322
297
  }
323
298
  }
324
299
 
@@ -364,7 +339,7 @@ function endBatch(): void {
364
339
  let eff: EffectNode | undefined = batchedEffect;
365
340
  batchedEffect = undefined;
366
341
  batchIteration++;
367
- if (batchIteration > 100) {
342
+ if (batchIteration > MAX_BATCH_ITERATIONS) {
368
343
  batchIteration = 0;
369
344
  throw new Error("Cycle detected");
370
345
  }
@@ -391,8 +366,7 @@ function endBatch(): void {
391
366
  }
392
367
  }
393
368
 
394
- function runEffect(eff: EffectNode): void {
395
- // Run cleanup first (untracked)
369
+ function runCleanup(eff: EffectNode): void {
396
370
  if (eff._cleanup) {
397
371
  const c = eff._cleanup;
398
372
  eff._cleanup = undefined;
@@ -406,6 +380,11 @@ function runEffect(eff: EffectNode): void {
406
380
  evalContext = prevContext;
407
381
  }
408
382
  }
383
+ }
384
+
385
+ function runEffect(eff: EffectNode): void {
386
+ // Run cleanup first (untracked)
387
+ runCleanup(eff);
409
388
 
410
389
  // Track dependencies
411
390
  const prevContext = evalContext;
@@ -426,19 +405,7 @@ function runEffect(eff: EffectNode): void {
426
405
 
427
406
  function disposeEffect(eff: EffectNode): void {
428
407
  // Run cleanup (untracked)
429
- if (eff._cleanup) {
430
- const c = eff._cleanup;
431
- eff._cleanup = undefined;
432
- const prevContext = evalContext;
433
- evalContext = undefined;
434
- try {
435
- c();
436
- } catch (e) {
437
- safeCallHandler(e);
438
- } finally {
439
- evalContext = prevContext;
440
- }
441
- }
408
+ runCleanup(eff);
442
409
 
443
410
  // Unsubscribe from all sources
444
411
  let node = eff._sources;
@@ -533,7 +500,7 @@ export function computed<T>(fn: () => T, options?: SignalOptions<T>): ReadonlySi
533
500
  _targets: undefined,
534
501
  _isComputed: true,
535
502
  // TargetNode fields
536
- _flags: DIRTY | TRACKING,
503
+ _flags: DIRTY,
537
504
  _sources: undefined,
538
505
  // ComputedNode-specific fields
539
506
  _fn: fn,
@@ -550,7 +517,7 @@ export function computed<T>(fn: () => T, options?: SignalOptions<T>): ReadonlySi
550
517
  throw new Error("Cycle detected");
551
518
  }
552
519
  // Refresh before tracking (pull-based glitch-free)
553
- if (node._flags & (DIRTY | CHECK)) {
520
+ if (node._flags & DIRTY) {
554
521
  refreshComputed(node);
555
522
  }
556
523
  const dep = addDependency(node);
@@ -567,7 +534,7 @@ export function computed<T>(fn: () => T, options?: SignalOptions<T>): ReadonlySi
567
534
  },
568
535
  peek(): T {
569
536
  // F6: refresh WITHOUT tracking
570
- if (node._flags & (DIRTY | CHECK)) {
537
+ if (node._flags & DIRTY) {
571
538
  const prevContext = evalContext;
572
539
  evalContext = undefined;
573
540
  try {
package/src/store.ts CHANGED
@@ -61,8 +61,11 @@ export function createStore<M>(): Store<M> {
61
61
  });
62
62
  }
63
63
 
64
- // A derived key: an effect that writes `outputKey` from `fn`. If `fn` reads
65
- // `outputKey` the engine throws "Cycle detected" rather than looping.
64
+ // A derived key: an effect that writes `outputKey` from `fn`. A `fn` that reads
65
+ // `outputKey` and yields a new value each run trips the engine's batch-iteration
66
+ // guard after ~100 re-runs, surfacing Error("Cycle detected") through the effect
67
+ // error handler (effects isolate errors, so it is NOT rethrown to the caller); a
68
+ // self-read that returns a stable value settles via Object.is dedup without looping.
66
69
  function computed<K extends keyof M & string>(outputKey: K, fn: () => M[K]): () => void {
67
70
  return effect(() => {
68
71
  set(outputKey, fn());