@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/README.md CHANGED
@@ -3,12 +3,25 @@
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
 
9
10
  > Signals + collections + DOM-reconciliation micro-framework for TypeScript
10
11
 
11
- A standalone reactive app-skeleton: fine-grained signals with automatic dependency tracking and synchronous batched effects, a typed per-key reactive store, a dynamic per-id signal registry (`SignalMap`), a reactive ordered keyed `Collection`, a two-tier list render binding (`bindList`), a CSP-safe element factory (`el`), keyed-list DOM reconciliation, structural tree-diffing, and a typed event bus (`createBus`). The store, `SignalMap`, and `Collection` are thin facades over the single signal engine — one reactive core, not several. The bus is the deliberate counterpart for discrete events (state lives in signals; events go on the bus). Zero dependencies beyond the DOM API.
12
+ A standalone reactive app-skeleton:
13
+
14
+ - fine-grained signals with automatic dependency tracking and synchronous batched effects
15
+ - a typed per-key reactive store
16
+ - a dynamic per-id signal registry (`SignalMap`)
17
+ - a reactive ordered keyed `Collection`
18
+ - a two-tier list render binding (`bindList`)
19
+ - a CSP-safe element factory (`el`)
20
+ - keyed-list DOM reconciliation
21
+ - structural tree-diffing
22
+ - a typed event bus (`createBus`)
23
+
24
+ The store, `SignalMap`, and `Collection` are thin facades over a single signal engine: one reactive core, not several. Zero dependencies beyond the DOM API.
12
25
 
13
26
  Mirrors semantics from @preact/signals-core and solid-js reactivity.
14
27
 
@@ -68,86 +81,93 @@ isComputed(doubled); // true
68
81
 
69
82
  ### Signals
70
83
 
71
- - `signal<T>(initial, options?): Signal<T>` — reactive value with `.value` getter/setter and `.peek()`. Options: `{ equals?: false | ((prev, next) => boolean) }`
72
- - `computed<T>(fn, options?): ReadonlySignal<T>` — lazy cached derived signal (glitch-free, equality-deduped). Options: `{ equals?: false | ((prev, next) => boolean) }`
73
- - `effect(fn: () => Cleanup): () => void` — auto-tracking side-effect with cleanup support. `fn` must return `undefined` or a cleanup function.
74
- - `batch(fn)` — coalesce signal writes; effects flush synchronously at end of outermost batch
75
- - `flushSync()` — synchronously drain pending effects (no-op inside batch)
76
- - `untracked<T>(fn): T` — run fn without tracking signal reads (like Preact `untracked()` / Solid `untrack()`)
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()`.
78
- - `subscribe(signal, cb): () => void` — subscribe to a signal, calling cb immediately and on every change
79
- - `isSignal(value): boolean` — type guard for signals created by `signal()`
80
- - `isComputed(value): boolean` — type guard for computed signals
81
- - `setEffectErrorHandler(handler): EffectErrorHandler` — set global error handler for effects; returns the previous handler
84
+ - `signal<T>(initial, options?): Signal<T>`: reactive value with `.value` getter/setter and `.peek()`. Options: `{ equals?: false | ((prev, next) => boolean) }`
85
+ - `computed<T>(fn, options?): ReadonlySignal<T>`: lazy cached derived signal (glitch-free, equality-deduped). Options: `{ equals?: false | ((prev, next) => boolean) }`
86
+ - `effect(fn: () => Cleanup): () => void`: auto-tracking side-effect with cleanup support. `fn` must return `undefined` or a cleanup function.
87
+ - `batch(fn)`: coalesce signal writes; effects flush synchronously at end of outermost batch
88
+ - `flushSync()`: synchronously drain pending effects (no-op inside batch)
89
+ - `untracked<T>(fn): T`: run fn without tracking signal reads (like Preact `untracked()` / Solid `untrack()`)
90
+ - `on(deps, fn, options?): () => U`: explicit dependency declaration helper (like Solid `on()`); pass into `effect()` or `computed()`. With `{ defer: true }` the accessor returns `() => U | undefined`: the deferred first call yields `undefined`.
91
+ - `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
92
+ - `isSignal(value): boolean`: type guard for signals created by `signal()`
93
+ - `isComputed(value): boolean`: type guard for computed signals
94
+ - `setEffectErrorHandler(handler): EffectErrorHandler`: set global error handler for effects; returns the previous handler
82
95
 
83
96
  ### DOM building & reconciliation
84
97
 
85
- - `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
- - `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
- - `reconcileChildren(parent, newChildren)` — low-level child reconciliation against existing DOM
89
- - `trackHandler(el, key)` — register an `on*` property for handler reconciliation during tree-diff
98
+ - `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.
99
+ - `reconcile<T>(parent, items, spec)`: keyed-list DOM reconciliation with mount/update/onRemove lifecycle
100
+ - `patch(parent, ...children)`: structural tree-diff, replacing a parent's children with reconciled new nodes. Element nodes are keyed by a `*-id` attribute (a specific entity id, which takes precedence) or, failing that, `data-col`, so reorders and re-patches reuse the matched node; duplicate-key siblings match in document order; unkeyed nodes match by position. A reused element takes the new node's attributes, its tracked `on*` handlers, and the unreflected live properties the attribute pass cannot reach: `checked` (input), `selected` (option) and the `value` of an `input`/`textarea`. Those reflect to `defaultChecked`/`defaultSelected`/`defaultValue`, so without this a re-render could never turn a checkbox off or replace the text in a field. Syncing `value` overwrites what a user has typed and moves the caret to the end, which is correct only where a re-render cannot land mid-keystroke: a form re-rendered by a click is safe, one re-rendered by a timer or a server push is not, and that shape must drive the field from an effect instead (see [Unsupported by Design](#unsupported-by-design)).
101
+ - `reconcileChildren(parent, newChildren)`: low-level child reconciliation against existing DOM
102
+ - `trackHandler(el, key)`: register an `on*` property for handler reconciliation during tree-diff
90
103
 
91
104
  ### Store
92
105
 
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.
106
+ `createStore` and `SignalMap` are thin facades over the one signal engine. `createStore` lazily backs each key with a signal; `SignalMap` is a registry of signals keyed by a runtime string id.
94
107
 
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
- - `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.
108
+ - `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, not a lazy engine computed: it writes `outputKey` on every dependency change. 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; `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 surfaces `Error("Cycle detected")` through the effect error handler, while a stable self-read settles. The guard re-arms per write: a key that is still cyclic reports again on the next write to it rather than reporting once and going quiet, and effects merely queued alongside the cycle (a `subscribe` on the same key, say) stay reactive.
109
+ - `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
110
 
98
111
  ### Collections
99
112
 
100
- 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`.
113
+ 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
114
 
102
- - `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
- - `upsert(item)` — add/replace one (appends new ids); `prepend(items)` — add to the front (scroll-up/load-older pagination)
115
+ - `createCollection<T>(keyOf: (item: T) => string): Collection<T>`: returns a collection with:
116
+ - `setAll(items)`: replace everything (same-order replacement does not bump `ids`; duplicate keys deduplicate: first occurrence keeps the position, last value wins)
117
+ - `upsert(item)`: add/replace one (appends new ids); `prepend(items)`: add to the front (scroll-up/load-older pagination)
105
118
  - `update(id, next | updater)` / `remove(id)` / `clear()`
106
119
  - `get(id)` / `has(id)` (untracked), `signalFor(id)` (reactive per-entity), `size`
107
- - `ids: ReadonlySignal<readonly string[]>` — structure tier (add/remove/reorder only)
108
- - `items()` — ordered reactive snapshot (tracks order + every entity)
120
+ - `ids: ReadonlySignal<readonly string[]>`: structure tier (add/remove/reorder only)
121
+ - `items()`: ordered reactive snapshot (tracks order + every entity)
109
122
 
110
123
  ### List rendering
111
124
 
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.
125
+ - `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 is a sliced view, not a separate primitive). A structural effect tracks `source.ids` and reconciles the row list; each row's own effect tracks only its 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
126
 
114
127
  ### Event bus
115
128
 
116
- State lives in signals; discrete events go on a bus. `createBus` is the typed event primitive (not reactive state — no retained value).
129
+ State lives in signals; discrete events go on a bus. `createBus` is the typed event primitive (not reactive state; no retained value).
117
130
 
118
- - `createBus<EventMap>(options?): Bus<EventMap>` — `on`/`once`/`off`/`emit`/`clear`. Handlers are snapshot-cached (rebuilt only on mutation; a handler unsubscribed mid-emit still fires for that emit). Events whose payload type is `undefined` emit with no payload argument. A throwing handler is isolated via `options.onError` (default `console.error`).
131
+ - `createBus<EventMap>(options?): Bus<EventMap>`: `on`/`once`/`off`/`emit`/`clear`. Handlers are snapshot-cached (rebuilt only on mutation; a handler unsubscribed mid-emit still fires for that emit). Events whose payload type is `undefined` emit with no payload argument. A throwing handler is isolated via `options.onError` (default `console.error`).
119
132
 
120
133
  ## Correctness guarantees
121
134
 
122
- - **Glitch-freedom**: Computed signals use equality dedup (`Object.is`) — downstream effects only fire when the computed value actually changes. Diamond dependency graphs are handled correctly.
135
+ - **Glitch-freedom**: Computed signals use equality dedup (`Object.is`), so downstream effects only fire when the computed value actually changes. Diamond dependency graphs are handled correctly.
123
136
  - **Cycle detection**: Reading a computed that is currently being computed throws `Error("Cycle detected")`.
124
137
  - **Error caching**: If a computed's fn throws, the error is cached and rethrown on subsequent reads until dependencies change.
125
138
  - **Computed is read-only**: Setting `.value` on a computed throws `Error("Cannot set a computed signal")`.
126
139
  - **Synchronous batch**: `batch()` flushes effects synchronously at the end of the outermost batch (matching @preact/signals-core and solid-js).
127
140
 
128
- ## Design Decisions — Unsupported by Design
141
+ ## Unsupported by Design
129
142
 
130
143
  The following features are intentionally NOT implemented:
131
144
 
132
- | Feature | Reason |
133
- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
134
- | **Effect ownership tree / `createRoot`** | Consumers manage disposal explicitly. Adding ownership changes the mental model. |
135
- | **Nested effect auto-disposal** | Each effect is independent and returns its own dispose function. Compose with arrays or helper functions. |
136
- | **Lazy activation / `onMount` lifecycle** | Signals are always active. Resource management is the consumer's responsibility. |
137
- | **`Signal.subtle.Watcher` / notify-on-dirty** | This library IS the framework layer. The Watcher pattern is for frameworks that sit on top of a signals primitive. |
138
- | **Introspection APIs** | Dev-tools concern. Not needed for production. |
139
- | **Explicit computed disposal** | Computed signals are GC'd when unreferenced. No explicit teardown needed. |
140
- | **SSR / server-side isolation** | Client-side library. Server usage should instantiate fresh signal graphs per request. |
141
- | **Async signals / resources** | Out of scope. Use effects + manual signal writes for async data loading. |
142
- | **Transactions** | Framework-level concern. Not a signals primitive. |
143
- | **Custom scheduler / `setScheduler()`** | Batch is synchronous. No scheduler needed. |
145
+ | Feature | Reason |
146
+ | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
147
+ | **Effect ownership tree / `createRoot`** | Consumers manage disposal explicitly. Adding ownership changes the mental model. |
148
+ | **Nested effect auto-disposal** | Each effect is independent and returns its own dispose function. Compose with arrays or helper functions. |
149
+ | **Lazy activation / `onMount` lifecycle** | Signals are always active. Resource management is the consumer's responsibility. |
150
+ | **`Signal.subtle.Watcher` / notify-on-dirty** | This library IS the framework layer. The Watcher pattern is for frameworks that sit on top of a signals primitive. |
151
+ | **Introspection APIs** | Dev-tools concern. Not needed for production. |
152
+ | **Explicit computed disposal** | Computed signals are GC'd when unreferenced. No explicit teardown needed. |
153
+ | **SSR / server-side isolation** | Client-side library. Server usage should instantiate fresh signal graphs per request. |
154
+ | **Async signals / resources** | Out of scope. Use effects + manual signal writes for async data loading. |
155
+ | **Transactions** | Framework-level concern. Not a signals primitive. |
156
+ | **Custom scheduler / `setScheduler()`** | Batch is synchronous. No scheduler needed. |
157
+ | **Controlled form inputs** | `patch()` syncs `checked`, `selected` and `input`/`textarea` `value`, so the render owns them. It has no change routing or state ownership, so a surface re-rendered while a user types must own the field itself. |
158
+ | **Waking subscribers of a self-writing computed** | A computed that writes a signal it reads is re-read correctly (it is marked stale so the next read re-runs it), but its own subscribers are not woken, so an effect depending on it does not re-run until something else notifies it. Writing a source from a computed body is outside the intended shape; do it in an effect. |
159
+
160
+ ## Contributing
161
+
162
+ Issues and PRs are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the
163
+ conventions and how to run the checks locally.
144
164
 
145
165
  ## Disclaimer
146
166
 
147
167
  This project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.
148
168
 
149
- This project was built with AI-assisted tooling using [Claude Opus](https://www.anthropic.com/claude) and [Kiro](https://kiro.dev). The human maintainer defines architecture, supervises implementation, and makes all final decisions.
169
+ This project was built with AI-assisted tooling using [Claude](https://claude.com), [GPT](https://openai.com), and [Kiro](https://kiro.dev). The human maintainer defines architecture, supervises implementation, and makes all final decisions.
150
170
 
151
171
  ## License
152
172
 
153
- GPL-3.0 — see [LICENSE](LICENSE).
173
+ Apache-2.0. See [LICENSE](LICENSE).
package/jsr.json CHANGED
@@ -1,6 +1,7 @@
1
1
  {
2
2
  "name": "@cplieger/reactive",
3
- "version": "1.2.4",
3
+ "version": "1.3.0",
4
+ "license": "Apache-2.0",
4
5
  "exports": {
5
6
  ".": "./src/index.ts"
6
7
  },
@@ -11,8 +12,7 @@
11
12
  "src/**/*.ts"
12
13
  ],
13
14
  "exclude": [
14
- "src/**/*.test.ts",
15
- "src/**/__test-helpers__/**"
15
+ "src/**/*.test.ts"
16
16
  ]
17
17
  }
18
18
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/reactive",
3
- "version": "1.2.4",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
@@ -13,18 +13,24 @@
13
13
  },
14
14
  "devDependencies": {
15
15
  "@eslint/js": "10.0.1",
16
+ "@stryker-mutator/core": "10.0.0",
17
+ "@stryker-mutator/typescript-checker": "10.0.0",
18
+ "@stryker-mutator/vitest-runner": "10.0.0",
16
19
  "@typescript/native": "npm:typescript@7.0.2",
17
- "@vitest/coverage-v8": "4.1.10",
18
- "eslint": "10.6.0",
19
- "fast-check": "4.8.0",
20
- "happy-dom": "20.10.6",
21
- "prettier": "3.9.4",
20
+ "@vitest/coverage-v8": "4.1.11",
21
+ "eslint": "10.8.1",
22
+ "fast-check": "4.9.0",
23
+ "happy-dom": "20.11.2",
24
+ "prettier": "3.9.6",
22
25
  "typescript": "npm:@typescript/typescript6@6.0.2",
23
- "typescript-eslint": "8.63.0",
24
- "vitest": "4.1.10"
26
+ "typescript-eslint": "8.67.0",
27
+ "vitest": "4.1.11"
28
+ },
29
+ "overrides": {
30
+ "qs": "^6.15.2"
25
31
  },
26
32
  "description": "Signals + DOM-reconciliation reactive core for TypeScript (zero dependencies)",
27
- "license": "GPL-3.0-or-later",
33
+ "license": "Apache-2.0",
28
34
  "types": "./src/index.ts",
29
35
  "publishConfig": {
30
36
  "access": "public"
@@ -33,11 +39,26 @@
33
39
  "type": "git",
34
40
  "url": "git+https://github.com/cplieger/reactive.git"
35
41
  },
42
+ "homepage": "https://github.com/cplieger/reactive",
43
+ "bugs": {
44
+ "url": "https://github.com/cplieger/reactive/issues"
45
+ },
46
+ "author": "cplieger <917744+cplieger@users.noreply.github.com>",
47
+ "keywords": [
48
+ "signals",
49
+ "reactivity",
50
+ "computed",
51
+ "effect",
52
+ "dom",
53
+ "reconciliation",
54
+ "store",
55
+ "event-bus",
56
+ "zero-dependency",
57
+ "typescript"
58
+ ],
36
59
  "files": [
37
60
  "src/",
38
- "!src/**/*.test.ts",
39
- "!src/**/__test-helpers__",
40
- "!src/**/fc-strict-setup.ts",
41
- "jsr.json"
61
+ "jsr.json",
62
+ "!src/**/*.test.ts"
42
63
  ]
43
64
  }
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,26 @@ 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
+ //
163
+ // The `data-col` scan is last-wins. The DOM keys attributes by (namespace,
164
+ // localName), so `setAttributeNS` with a foreign namespace and no prefix can
165
+ // put a second attribute whose `.name` is also "data-col" on the element;
166
+ // first-wins would key the node off the foreign one and refuse to match the
167
+ // author's own, destroying and recreating the node instead of reusing it.
168
+ let colKey = "";
142
169
  for (const attr of (node as Element).attributes) {
143
- if (attr.name === "data-col" || attr.name.endsWith("-id")) {
170
+ if (attr.name.endsWith("-id")) {
144
171
  return `${attr.name}=${attr.value}`;
145
172
  }
173
+ if (attr.name === "data-col") {
174
+ colKey = `${attr.name}=${attr.value}`;
175
+ }
146
176
  }
147
- return "";
177
+ return colKey;
148
178
  }
149
179
 
150
180
  function patchAttrs(oldEl: HTMLElement, newEl: HTMLElement): void {
@@ -158,8 +188,55 @@ function patchAttrs(oldEl: HTMLElement, newEl: HTMLElement): void {
158
188
  oldEl.removeAttribute(attr.name);
159
189
  }
160
190
  }
161
- if (oldEl.hidden !== newEl.hidden) {
162
- oldEl.hidden = newEl.hidden;
191
+
192
+ // Unreflected live state. `checked` (input), `selected` (option) and the
193
+ // `value` of an input/textarea are dirty-flag IDL properties: their content
194
+ // attributes are the DEFAULTS (`defaultChecked` / `defaultSelected` /
195
+ // `defaultValue`), so the two loops above equalise the default and never the
196
+ // live value. Without this, a keyed re-patch could not turn a checkbox off,
197
+ // move a selection, or replace the text in a field, ever.
198
+ //
199
+ // Syncing `value` overwrites whatever a user has typed, and it moves the caret
200
+ // to the end. That is correct only where a re-render cannot land mid-keystroke,
201
+ // which is a property of the CONSUMER, not of this function: a form re-rendered
202
+ // by a click (open, save, reset) is safe, one re-rendered by a timer or a server
203
+ // push is not. The README records the obligation; a consumer in the second shape
204
+ // must drive the field from an effect instead of re-rendering over it.
205
+ //
206
+ // Not covered here: `value` on an <option>/<progress>/<li> DOES reflect, so the
207
+ // loops above already carry it. A <select>'s value is derived from its options,
208
+ // which the OPTION branch below reconciles. `indeterminate` is unreflected too,
209
+ // but el() cannot set it, so it is outside patch()'s reach.
210
+ //
211
+ // canPatch has already established that both nodes share a nodeName, so one
212
+ // side decides the branch.
213
+ //
214
+ // Each `!==` guard is deliberate and no test here can pin it: assigning the
215
+ // same string to `.value` is a no-op through the DOM, but in a real browser it
216
+ // sets the dirty flag and moves the caret to the end, so an unconditional write
217
+ // would jump the caret on every re-render even when nothing changed. happy-dom
218
+ // exposes no caret, hence a permanently surviving `-> if (true)` mutant on each.
219
+ if (oldEl.nodeName === "INPUT") {
220
+ const oldInput = oldEl as HTMLInputElement;
221
+ const newInput = newEl as HTMLInputElement;
222
+ if (oldInput.checked !== newInput.checked) {
223
+ oldInput.checked = newInput.checked;
224
+ }
225
+ if (oldInput.value !== newInput.value) {
226
+ oldInput.value = newInput.value;
227
+ }
228
+ } else if (oldEl.nodeName === "TEXTAREA") {
229
+ const oldArea = oldEl as HTMLTextAreaElement;
230
+ const newArea = newEl as HTMLTextAreaElement;
231
+ if (oldArea.value !== newArea.value) {
232
+ oldArea.value = newArea.value;
233
+ }
234
+ } else if (oldEl.nodeName === "OPTION") {
235
+ const oldOption = oldEl as HTMLOptionElement;
236
+ const newOption = newEl as HTMLOptionElement;
237
+ if (oldOption.selected !== newOption.selected) {
238
+ oldOption.selected = newOption.selected;
239
+ }
163
240
  }
164
241
 
165
242
  // Reconcile on* event handler properties (not reflected as attributes).