@cplieger/reactive 1.1.0 → 1.2.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/LICENSE CHANGED
@@ -689,4 +689,4 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
689
689
  GNU General Public License for more details.
690
690
 
691
691
  You should have received a copy of the GNU General Public License
692
- along with this program. If not, see <https://www.gnu.org/licenses/>.
692
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
package/README.md CHANGED
@@ -1,9 +1,10 @@
1
1
  # reactive
2
2
 
3
- [![CI](https://github.com/cplieger/reactive/actions/workflows/ci.yaml/badge.svg)](https://github.com/cplieger/reactive/actions/workflows/ci.yaml)
4
3
  [![npm](https://img.shields.io/npm/v/@cplieger/reactive)](https://www.npmjs.com/package/@cplieger/reactive)
5
4
  [![JSR](https://jsr.io/badges/@cplieger/reactive)](https://jsr.io/@cplieger/reactive)
6
- [![License: GPL-3.0](https://img.shields.io/badge/License-GPL--3.0-blue.svg)](LICENSE)
5
+ [![Test coverage](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/cplieger/reactive/badges/coverage.json)](https://github.com/cplieger/reactive/actions/workflows/coverage.yml)
6
+ [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13218/badge)](https://www.bestpractices.dev/projects/13218)
7
+ [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/cplieger/reactive/badge)](https://scorecard.dev/viewer/?uri=github.com/cplieger/reactive)
7
8
 
8
9
  > Signals + collections + DOM-reconciliation micro-framework for TypeScript
9
10
 
@@ -73,7 +74,7 @@ isComputed(doubled); // true
73
74
  - `batch(fn)` — coalesce signal writes; effects flush synchronously at end of outermost batch
74
75
  - `flushSync()` — synchronously drain pending effects (no-op inside batch)
75
76
  - `untracked<T>(fn): T` — run fn without tracking signal reads (like Preact `untracked()` / Solid `untrack()`)
76
- - `on(deps, fn, options?): () => U | undefined` — explicit dependency declaration helper (like Solid `on()`). Pass into `effect()` or `computed()`.
77
+ - `on(deps, fn, options?): () => U` — explicit dependency declaration helper (like Solid `on()`). Returns `() => U`; with `{ defer: true }` the accessor returns `() => U | undefined` (the deferred first call yields `undefined`). A single accessor `() => T` types `value`/`prev` as `T`; an array of accessors types them as `unknown[]`. Pass into `effect()` or `computed()`.
77
78
  - `subscribe(signal, cb): () => void` — subscribe to a signal, calling cb immediately and on every change
78
79
  - `isSignal(value): boolean` — type guard for signals created by `signal()`
79
80
  - `isComputed(value): boolean` — type guard for computed signals
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/reactive",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
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.1.0",
3
+ "version": "1.2.1",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
@@ -13,14 +13,14 @@
13
13
  },
14
14
  "devDependencies": {
15
15
  "@eslint/js": "10.0.1",
16
- "@vitest/coverage-v8": "4.1.8",
17
- "eslint": "10.4.1",
16
+ "@vitest/coverage-v8": "4.1.9",
17
+ "eslint": "10.5.0",
18
18
  "fast-check": "4.8.0",
19
- "happy-dom": "20.10.2",
20
- "prettier": "3.8.3",
19
+ "happy-dom": "20.10.6",
20
+ "prettier": "3.8.4",
21
21
  "typescript": "6.0.3",
22
- "typescript-eslint": "8.60.1",
23
- "vitest": "4.1.8"
22
+ "typescript-eslint": "8.62.0",
23
+ "vitest": "4.1.9"
24
24
  },
25
25
  "description": "Signals + DOM-reconciliation reactive core for TypeScript (zero dependencies)",
26
26
  "license": "GPL-3.0-or-later",
package/src/el.ts CHANGED
@@ -51,12 +51,11 @@ export function el(
51
51
  }
52
52
  if (k === "className") {
53
53
  e.className = v as string;
54
- } else if (k.startsWith("on")) {
54
+ } else if (/^on/i.test(k)) {
55
55
  (e as unknown as Record<string, unknown>)[k] = v;
56
56
  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)) {
57
+ } else if (BOOL_PROPS.has(k) || VALUE_PROPS.has(k)) {
58
+ // Both sets are assigned as DOM properties, not attributes.
60
59
  (e as unknown as Record<string, unknown>)[k] = v;
61
60
  } else {
62
61
  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 {
@@ -662,7 +629,39 @@ export function isComputed(value: unknown): value is ReadonlySignal<unknown> {
662
629
  return value !== null && typeof value === "object" && COMPUTED_BRAND in value;
663
630
  }
664
631
 
665
- /** Explicit dependency declaration helper. Mirrors Solid's on(). */
632
+ /** Explicit dependency declaration helper. Mirrors Solid's on().
633
+ *
634
+ * Overloaded so the return type and the `value`/`prev` parameter types stay
635
+ * tight:
636
+ * - Without `defer` (or `{ defer: false }`) the accessor always returns `U`;
637
+ * only the `{ defer: true | boolean }` form widens to `U | undefined`
638
+ * (the deferred first call yields `undefined`).
639
+ * - A single accessor `() => T` correlates `value`/`prev` to `T`; an array of
640
+ * accessors correlates them to `unknown[]`. */
641
+ // Single accessor, non-deferred (or explicit `{ defer: false }`): never undefined.
642
+ export function on<T, U>(
643
+ deps: () => T,
644
+ fn: (value: T, prev: T | undefined, prevResult: U | undefined) => U,
645
+ options?: { defer?: false },
646
+ ): () => U;
647
+ // Single accessor, deferred: first call returns undefined.
648
+ export function on<T, U>(
649
+ deps: () => T,
650
+ fn: (value: T, prev: T | undefined, prevResult: U | undefined) => U,
651
+ options: { defer: boolean },
652
+ ): () => U | undefined;
653
+ // Array of accessors, non-deferred (or explicit `{ defer: false }`): never undefined.
654
+ export function on<U>(
655
+ deps: (() => unknown)[],
656
+ fn: (value: unknown[], prev: unknown[] | undefined, prevResult: U | undefined) => U,
657
+ options?: { defer?: false },
658
+ ): () => U;
659
+ // Array of accessors, deferred: first call returns undefined.
660
+ export function on<U>(
661
+ deps: (() => unknown)[],
662
+ fn: (value: unknown[], prev: unknown[] | undefined, prevResult: U | undefined) => U,
663
+ options: { defer: boolean },
664
+ ): () => U | undefined;
666
665
  export function on<T, U>(
667
666
  deps: (() => T) | (() => unknown)[],
668
667
  fn: (value: T | unknown[], prev: T | unknown[] | undefined, prevResult: U | undefined) => U,
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());