@cplieger/reactive 1.2.4 → 1.2.5

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
@@ -3,6 +3,7 @@
3
3
  [![npm](https://img.shields.io/npm/v/@cplieger/reactive)](https://www.npmjs.com/package/@cplieger/reactive)
4
4
  [![JSR](https://jsr.io/badges/@cplieger/reactive)](https://jsr.io/@cplieger/reactive)
5
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
+ [![Mutation (TS)](https://img.shields.io/endpoint?url=https://raw.githubusercontent.com/cplieger/reactive/badges/mutation-ts.json)](https://github.com/cplieger/reactive/issues?q=label%3Astryker-tracker)
6
7
  [![OpenSSF Best Practices](https://www.bestpractices.dev/projects/13218/badge)](https://www.bestpractices.dev/projects/13218)
7
8
  [![OpenSSF Scorecard](https://api.scorecard.dev/projects/github.com/cplieger/reactive/badge)](https://scorecard.dev/viewer/?uri=github.com/cplieger/reactive)
8
9
 
@@ -75,7 +76,7 @@ isComputed(doubled); // true
75
76
  - `flushSync()` — synchronously drain pending effects (no-op inside batch)
76
77
  - `untracked<T>(fn): T` — run fn without tracking signal reads (like Preact `untracked()` / Solid `untrack()`)
77
78
  - `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()`.
78
- - `subscribe(signal, cb): () => void` — subscribe to a signal, calling cb immediately and on every change
79
+ - `subscribe(signal, cb): () => void` — subscribe to a signal, calling cb immediately and on every change. The callback runs untracked: signals read inside it do not become dependencies of the subscription
79
80
  - `isSignal(value): boolean` — type guard for signals created by `signal()`
80
81
  - `isComputed(value): boolean` — type guard for computed signals
81
82
  - `setEffectErrorHandler(handler): EffectErrorHandler` — set global error handler for effects; returns the previous handler
@@ -84,7 +85,7 @@ isComputed(doubled); // true
84
85
 
85
86
  - `el(tag, attrs?, ...children)` — CSP-safe element factory (the build half; `reconcile`/`patch` are the commit half). `className` → class; `on*` → handler property + `trackHandler` (so `patch` reconciles handlers); boolean DOM props (`hidden`/`disabled`/`checked`/`selected`/…) and `value`/`colSpan`/`rowSpan`/`tabIndex`/`htmlFor` → properties; everything else (`data-*`, `aria-*`, `style`, `id`) → `setAttribute`. String children become text nodes (never parsed as HTML); null/undefined attrs and children are skipped.
86
87
  - `reconcile<T>(parent, items, spec)` — keyed-list DOM reconciliation with mount/update/onRemove lifecycle
87
- - `patch(parent, ...children)` — structural tree-diff, replacing a parent's children with reconciled new nodes. Element nodes are keyed by their first `data-col` or `*-id` attribute (so reorders/re-patches reuse the matched node); unkeyed nodes match by position.
88
+ - `patch(parent, ...children)` — structural tree-diff, replacing a parent's children with reconciled new nodes. Element nodes are keyed by a `*-id` attribute (specific entity id — takes precedence) or, failing that, `data-col` (so reorders/re-patches reuse the matched node); duplicate-key siblings match in document order; unkeyed nodes match by position.
88
89
  - `reconcileChildren(parent, newChildren)` — low-level child reconciliation against existing DOM
89
90
  - `trackHandler(el, key)` — register an `on*` property for handler reconciliation during tree-diff
90
91
 
@@ -92,7 +93,7 @@ isComputed(doubled); // true
92
93
 
93
94
  `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
95
 
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
+ - `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), and its callback runs untracked. `computed(outputKey, fn)` is an EAGER effect that writes `outputKey` on every dependency change — not a lazy engine computed — so `fn` runs whether or not anyone reads the key, a throwing `fn` routes to the effect error handler instead of being cached and rethrown at the read site, and `set(outputKey, …)` still works between recomputes. 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
97
  - `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
98
 
98
99
  ### Collections
@@ -100,7 +101,7 @@ isComputed(doubled); // true
100
101
  A reactive ordered collection of keyed entities — the data half of the two-tier list pattern. A per-entity content update touches only that entity's subscribers; add/remove/reorder bumps the structure signal (`ids`). Built on `signal` + `SignalMap`.
101
102
 
102
103
  - `createCollection<T>(keyOf: (item: T) => string): Collection<T>` — returns a collection with:
103
- - `setAll(items)` — replace everything (same-order replacement does not bump `ids`)
104
+ - `setAll(items)` — replace everything (same-order replacement does not bump `ids`; duplicate keys deduplicate — first occurrence keeps the position, last value wins)
104
105
  - `upsert(item)` — add/replace one (appends new ids); `prepend(items)` — add to the front (scroll-up/load-older pagination)
105
106
  - `update(id, next | updater)` / `remove(id)` / `clear()`
106
107
  - `get(id)` / `has(id)` (untracked), `signalFor(id)` (reactive per-entity), `size`
@@ -109,7 +110,7 @@ A reactive ordered collection of keyed entities — the data half of the two-tie
109
110
 
110
111
  ### List rendering
111
112
 
112
- - `bindList<T>(parent, source, spec): () => void` — two-tier render binding. `source` is a `ListSource<T>` = `{ ids, signalFor }`; a `Collection` satisfies it directly, and a filtered/sorted/paginated view is just `{ ids: computed(...), signalFor: collection.signalFor }` (pagination = a sliced view, not a separate primitive). One structural effect tracks `source.ids` and `reconcile`s the row list; each row owns a private effect tracking only its own entity signal, so a per-entity change repaints just that row with no structural reconcile. `spec`: `{ mount(item, id) => HTMLElement; update?(el, item, id); onRemove?(el, id) }` (`update` runs at mount and on every later change). Returns a dispose that tears down the structural effect and every row effect.
113
+ - `bindList<T>(parent, source, spec): () => void` — two-tier render binding. `source` is a `ListSource<T>` = `{ ids, signalFor }`; a `Collection` satisfies it directly, and a filtered/sorted/paginated view is just `{ ids: computed(...), signalFor: collection.signalFor }` (pagination = a sliced view, not a separate primitive). One structural effect tracks `source.ids` and `reconcile`s the row list; each row owns a private effect tracking only its own entity signal, so a per-entity change repaints just that row with no structural reconcile. An id whose `signalFor` is `undefined` (an inconsistent source) is skipped rather than rendered. `spec`: `{ mount(item, id) => HTMLElement; update?(el, item, id); onRemove?(el, id) }` (`update` runs at mount and on every later change). Returns a dispose that tears down the structural effect and every row effect.
113
114
 
114
115
  ### Event bus
115
116
 
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/reactive",
3
- "version": "1.2.4",
3
+ "version": "1.2.5",
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.4",
3
+ "version": "1.2.5",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
@@ -13,14 +13,17 @@
13
13
  },
14
14
  "devDependencies": {
15
15
  "@eslint/js": "10.0.1",
16
+ "@stryker-mutator/core": "9.6.1",
17
+ "@stryker-mutator/typescript-checker": "9.6.1",
18
+ "@stryker-mutator/vitest-runner": "9.6.1",
16
19
  "@typescript/native": "npm:typescript@7.0.2",
17
20
  "@vitest/coverage-v8": "4.1.10",
18
- "eslint": "10.6.0",
19
- "fast-check": "4.8.0",
21
+ "eslint": "10.7.0",
22
+ "fast-check": "4.9.0",
20
23
  "happy-dom": "20.10.6",
21
- "prettier": "3.9.4",
24
+ "prettier": "3.9.5",
22
25
  "typescript": "npm:@typescript/typescript6@6.0.2",
23
- "typescript-eslint": "8.63.0",
26
+ "typescript-eslint": "8.64.0",
24
27
  "vitest": "4.1.10"
25
28
  },
26
29
  "description": "Signals + DOM-reconciliation reactive core for TypeScript (zero dependencies)",
package/src/bind-list.ts CHANGED
@@ -19,7 +19,9 @@ import { effect, type ReadonlySignal } from "./signal.js";
19
19
 
20
20
  /** The minimal reactive surface `bindList` renders: a structure signal (the
21
21
  * ordered ids) and a per-id content-signal lookup. `Collection<T>` satisfies
22
- * this; so does `{ ids: computed(...), signalFor: collection.signalFor }`. */
22
+ * this; so does `{ ids: computed(...), signalFor: collection.signalFor }`.
23
+ * An id with no matching signal (an inconsistent source) is skipped — no row
24
+ * is rendered for it until the source becomes consistent. */
23
25
  export interface ListSource<T> {
24
26
  readonly ids: ReadonlySignal<readonly string[]>;
25
27
  signalFor(id: string): ReadonlySignal<T> | undefined;
@@ -51,12 +53,23 @@ export function bindList<T>(
51
53
  const rowDisposers = new Map<string, () => void>();
52
54
 
53
55
  const disposeStructural = effect(() => {
54
- const ids = source.ids.value; // structure tier: re-runs on add/remove/reorder only
56
+ // Structure tier: re-runs on add/remove/reorder only. An id whose entity
57
+ // signal is missing (an inconsistent ListSource) is skipped instead of
58
+ // mounting a row with a manufactured `undefined as T` value; it renders
59
+ // once the source becomes consistent (the next `ids` change).
60
+ const ids = source.ids.value.filter((id) => source.signalFor(id) !== undefined);
55
61
  reconcile<string>(parent, ids, {
56
62
  key: (id) => id,
57
63
  mount: (id) => {
58
64
  const sig = source.signalFor(id);
59
- const el = spec.mount((sig === undefined ? undefined : sig.peek()) as T, id);
65
+ if (sig === undefined) {
66
+ // Unreachable via the structural filter above (same synchronous
67
+ // pass); reachable only by a source whose signalFor is not stable
68
+ // within one read — surface that loudly instead of manufacturing
69
+ // an entity value. Routed to the effect error handler.
70
+ throw new Error(`bindList: no entity signal for id "${id}"`);
71
+ }
72
+ const el = spec.mount(sig.peek(), id);
60
73
  if (spec.update !== undefined) {
61
74
  const update = spec.update;
62
75
  // content tier: isolated per-row effect; runs now (fills content)
package/src/collection.ts CHANGED
@@ -12,7 +12,10 @@ import { SignalMap } from "./signal-map.js";
12
12
  /** A reactive, ordered, keyed collection of entities of type T. */
13
13
  export interface Collection<T> {
14
14
  /** Replace the whole collection (e.g. initial load). Entities no longer
15
- * present are dropped; the order becomes exactly `items`. */
15
+ * present are dropped; the order becomes `items`, deduplicated by key
16
+ * (first occurrence keeps the position, the last value wins — Map
17
+ * semantics), so the order tier can never list an id twice while the
18
+ * entity tier holds a single signal for it. */
16
19
  setAll(items: readonly T[]): void;
17
20
  /** Add a new entity or replace an existing one (keyed by `keyOf`). New
18
21
  * entities are appended to the order. */
@@ -65,8 +68,13 @@ export function createCollection<T>(keyOf: (item: T) => string): Collection<T> {
65
68
  const seen = new Set<string>();
66
69
  for (const item of items) {
67
70
  const id = keyOf(item);
68
- ids.push(id);
69
- seen.add(id);
71
+ // Deduplicate repeated keys (first occurrence keeps the position, the
72
+ // last value wins) so the structure and entity tiers cannot disagree:
73
+ // one signal per id, one order entry per id.
74
+ if (!seen.has(id)) {
75
+ seen.add(id);
76
+ ids.push(id);
77
+ }
70
78
  sigs.ensure(id, item).value = item;
71
79
  }
72
80
  for (const id of order.peek()) {
@@ -1,6 +1,8 @@
1
1
  // Structural tree-diff: reconcile a parent's children against new nodes.
2
2
  // Handles attribute patching, text node updates, element reordering,
3
- // and recursive child reconciliation. Keying via data-* or *-id attrs.
3
+ // and recursive child reconciliation. Keying: a specific `*-id` attribute
4
+ // wins over the generic `data-col`; duplicate-key siblings match in
5
+ // document order.
4
6
 
5
7
  const handlerKeysMap = new WeakMap<HTMLElement, Set<string>>();
6
8
 
@@ -38,11 +40,19 @@ export function patch(
38
40
  /** Reconcile a parent node's children against a new set of child nodes, patching in place. */
39
41
  export function reconcileChildren(parent: Node, newChildren: Node[]): void {
40
42
  const oldChildren = Array.from(parent.childNodes);
41
- const oldByKey = new Map<string, Node>();
43
+ // Per-key FIFO queues: siblings sharing a key (e.g. two `data-col="meta"`
44
+ // cells in one row) match in document order (first↔first, second↔second)
45
+ // instead of a last-wins map entry mispairing them and churning nodes.
46
+ const oldByKey = new Map<string, Node[]>();
42
47
  for (const child of oldChildren) {
43
48
  const key = nodeKey(child);
44
49
  if (key) {
45
- oldByKey.set(key, child);
50
+ const queue = oldByKey.get(key);
51
+ if (queue === undefined) {
52
+ oldByKey.set(key, [child]);
53
+ } else {
54
+ queue.push(child);
55
+ }
46
56
  }
47
57
  }
48
58
 
@@ -54,10 +64,16 @@ export function reconcileChildren(parent: Node, newChildren: Node[]): void {
54
64
  }
55
65
  const newKey = nodeKey(newChild);
56
66
 
57
- let matched = newKey ? (oldByKey.get(newKey) ?? null) : null;
58
- if (matched) {
59
- oldByKey.delete(newKey);
60
- } else if (!newKey) {
67
+ let matched: Node | null = null;
68
+ if (newKey) {
69
+ const queue = oldByKey.get(newKey);
70
+ if (queue !== undefined) {
71
+ matched = queue.shift() ?? null;
72
+ if (queue.length === 0) {
73
+ oldByKey.delete(newKey);
74
+ }
75
+ }
76
+ } else {
61
77
  // Unkeyed new node: match the next UNKEYED old node by position, advancing
62
78
  // past keyed old nodes (which are reserved for key-based matching) and any
63
79
  // already-consumed slots. This mirrors the documented contract ("unkeyed
@@ -139,12 +155,20 @@ function nodeKey(node: Node): string {
139
155
  if (node.nodeType !== 1) {
140
156
  return "";
141
157
  }
158
+ // A specific entity id (`*-id`, e.g. data-cov-id / data-act-id) takes
159
+ // precedence over the generic column key (`data-col`), so an element
160
+ // carrying both keys by its entity identity — a generic first-in-attribute-
161
+ // order `data-col` must not shadow it.
162
+ let colKey = "";
142
163
  for (const attr of (node as Element).attributes) {
143
- if (attr.name === "data-col" || attr.name.endsWith("-id")) {
164
+ if (attr.name.endsWith("-id")) {
144
165
  return `${attr.name}=${attr.value}`;
145
166
  }
167
+ if (colKey === "" && attr.name === "data-col") {
168
+ colKey = `${attr.name}=${attr.value}`;
169
+ }
146
170
  }
147
- return "";
171
+ return colKey;
148
172
  }
149
173
 
150
174
  function patchAttrs(oldEl: HTMLElement, newEl: HTMLElement): void {
package/src/signal.ts CHANGED
@@ -2,6 +2,19 @@
2
2
  // Preact-style doubly-linked source/target edges, pull-based glitch-free refresh,
3
3
  // global epoch + per-node version fast-skip, bitfield flags.
4
4
  // GPL-3.0-or-later.
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); cycle guard in endBatch (upstream also
12
+ // guards in the signal setter). Upstream machinery NOT ported by design:
13
+ // watched/unwatched hooks, createModel/action (1.13+), and the 1.14.x
14
+ // batch-snapshot A->B->A revert optimization — the version-monotonicity bug
15
+ // its first release carried (preactjs/signals#947) never existed here.
16
+ // Re-audit on upstream releases: diff src/index.ts against this file's
17
+ // mechanisms and harvest engine-internal fixes (see CONTRIBUTING.md).
5
18
 
6
19
  const SIGNAL_BRAND = Symbol("signal");
7
20
  const COMPUTED_BRAND = Symbol("computed");
@@ -608,13 +621,19 @@ export function untracked<T>(fn: () => T): T {
608
621
  }
609
622
  }
610
623
 
611
- /** Subscribe to a signal, calling cb on every change. Returns dispose function. */
624
+ /** Subscribe to a signal, calling cb on every change. Returns dispose function.
625
+ * The callback runs untracked: reading other signals inside it does not add
626
+ * them as dependencies of the subscription (matches upstream
627
+ * preactjs/signals#188 semantics). */
612
628
  export function subscribe<T>(
613
629
  sig: Signal<T> | ReadonlySignal<T>,
614
630
  cb: (value: T) => void,
615
631
  ): () => void {
616
632
  return effect(() => {
617
- cb(sig.value);
633
+ const value = sig.value;
634
+ untracked(() => {
635
+ cb(value);
636
+ });
618
637
  return undefined;
619
638
  });
620
639
  }
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