@cplieger/reactive 1.2.5 → 1.3.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/README.md CHANGED
@@ -9,7 +9,19 @@
9
9
 
10
10
  > Signals + collections + DOM-reconciliation micro-framework for TypeScript
11
11
 
12
- 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.
13
25
 
14
26
  Mirrors semantics from @preact/signals-core and solid-js reactivity.
15
27
 
@@ -69,86 +81,93 @@ isComputed(doubled); // true
69
81
 
70
82
  ### Signals
71
83
 
72
- - `signal<T>(initial, options?): Signal<T>` — reactive value with `.value` getter/setter and `.peek()`. Options: `{ equals?: false | ((prev, next) => boolean) }`
73
- - `computed<T>(fn, options?): ReadonlySignal<T>` — lazy cached derived signal (glitch-free, equality-deduped). Options: `{ equals?: false | ((prev, next) => boolean) }`
74
- - `effect(fn: () => Cleanup): () => void` — auto-tracking side-effect with cleanup support. `fn` must return `undefined` or a cleanup function.
75
- - `batch(fn)` — coalesce signal writes; effects flush synchronously at end of outermost batch
76
- - `flushSync()` — synchronously drain pending effects (no-op inside batch)
77
- - `untracked<T>(fn): T` — run fn without tracking signal reads (like Preact `untracked()` / Solid `untrack()`)
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()`.
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
80
- - `isSignal(value): boolean` — type guard for signals created by `signal()`
81
- - `isComputed(value): boolean` — type guard for computed signals
82
- - `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
83
95
 
84
96
  ### DOM building & reconciliation
85
97
 
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.
87
- - `reconcile<T>(parent, items, spec)` — keyed-list DOM reconciliation with mount/update/onRemove lifecycle
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.
89
- - `reconcileChildren(parent, newChildren)` — low-level child reconciliation against existing DOM
90
- - `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
91
103
 
92
104
  ### Store
93
105
 
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.
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.
95
107
 
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.
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.
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.
98
110
 
99
111
  ### Collections
100
112
 
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`.
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`.
102
114
 
103
- - `createCollection<T>(keyOf: (item: T) => string): Collection<T>` — returns a collection with:
104
- - `setAll(items)` — replace everything (same-order replacement does not bump `ids`; duplicate keys deduplicate — first occurrence keeps the position, last value wins)
105
- - `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)
106
118
  - `update(id, next | updater)` / `remove(id)` / `clear()`
107
119
  - `get(id)` / `has(id)` (untracked), `signalFor(id)` (reactive per-entity), `size`
108
- - `ids: ReadonlySignal<readonly string[]>` — structure tier (add/remove/reorder only)
109
- - `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)
110
122
 
111
123
  ### List rendering
112
124
 
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.
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.
114
126
 
115
127
  ### Event bus
116
128
 
117
- 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).
118
130
 
119
- - `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`).
120
132
 
121
133
  ## Correctness guarantees
122
134
 
123
- - **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.
124
136
  - **Cycle detection**: Reading a computed that is currently being computed throws `Error("Cycle detected")`.
125
137
  - **Error caching**: If a computed's fn throws, the error is cached and rethrown on subsequent reads until dependencies change.
126
138
  - **Computed is read-only**: Setting `.value` on a computed throws `Error("Cannot set a computed signal")`.
127
139
  - **Synchronous batch**: `batch()` flushes effects synchronously at the end of the outermost batch (matching @preact/signals-core and solid-js).
128
140
 
129
- ## Design Decisions — Unsupported by Design
141
+ ## Unsupported by Design
130
142
 
131
143
  The following features are intentionally NOT implemented:
132
144
 
133
- | Feature | Reason |
134
- | --------------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
135
- | **Effect ownership tree / `createRoot`** | Consumers manage disposal explicitly. Adding ownership changes the mental model. |
136
- | **Nested effect auto-disposal** | Each effect is independent and returns its own dispose function. Compose with arrays or helper functions. |
137
- | **Lazy activation / `onMount` lifecycle** | Signals are always active. Resource management is the consumer's responsibility. |
138
- | **`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. |
139
- | **Introspection APIs** | Dev-tools concern. Not needed for production. |
140
- | **Explicit computed disposal** | Computed signals are GC'd when unreferenced. No explicit teardown needed. |
141
- | **SSR / server-side isolation** | Client-side library. Server usage should instantiate fresh signal graphs per request. |
142
- | **Async signals / resources** | Out of scope. Use effects + manual signal writes for async data loading. |
143
- | **Transactions** | Framework-level concern. Not a signals primitive. |
144
- | **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.
145
164
 
146
165
  ## Disclaimer
147
166
 
148
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.
149
168
 
150
- 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.
151
170
 
152
171
  ## License
153
172
 
154
- 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.5",
3
+ "version": "1.3.1",
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.5",
3
+ "version": "1.3.1",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
@@ -13,21 +13,24 @@
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
+ "@stryker-mutator/core": "10.0.0",
17
+ "@stryker-mutator/typescript-checker": "10.0.0",
18
+ "@stryker-mutator/vitest-runner": "10.0.0",
19
19
  "@typescript/native": "npm:typescript@7.0.2",
20
- "@vitest/coverage-v8": "4.1.10",
21
- "eslint": "10.7.0",
20
+ "@vitest/coverage-v8": "4.1.11",
21
+ "eslint": "10.8.1",
22
22
  "fast-check": "4.9.0",
23
- "happy-dom": "20.10.6",
24
- "prettier": "3.9.5",
23
+ "happy-dom": "20.11.6",
24
+ "prettier": "3.9.6",
25
25
  "typescript": "npm:@typescript/typescript6@6.0.2",
26
- "typescript-eslint": "8.64.0",
27
- "vitest": "4.1.10"
26
+ "typescript-eslint": "8.67.0",
27
+ "vitest": "4.1.11"
28
+ },
29
+ "overrides": {
30
+ "qs": "^6.15.2"
28
31
  },
29
32
  "description": "Signals + DOM-reconciliation reactive core for TypeScript (zero dependencies)",
30
- "license": "GPL-3.0-or-later",
33
+ "license": "Apache-2.0",
31
34
  "types": "./src/index.ts",
32
35
  "publishConfig": {
33
36
  "access": "public"
@@ -36,11 +39,26 @@
36
39
  "type": "git",
37
40
  "url": "git+https://github.com/cplieger/reactive.git"
38
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
+ ],
39
59
  "files": [
40
60
  "src/",
41
- "!src/**/*.test.ts",
42
- "!src/**/__test-helpers__",
43
- "!src/**/fc-strict-setup.ts",
44
- "jsr.json"
61
+ "jsr.json",
62
+ "!src/**/*.test.ts"
45
63
  ]
46
64
  }
@@ -57,6 +57,15 @@ export function reconcileChildren(parent: Node, newChildren: Node[]): void {
57
57
  }
58
58
 
59
59
  let oldIdx = 0;
60
+ // Old nodes already placed as the resolved child for an earlier index. The
61
+ // keyed path cannot hand one back — each key is consumed from its FIFO queue —
62
+ // but `oldChildren` is a SNAPSHOT taken before the loop, so a node still
63
+ // occupying its original slot there can be handed back by the positional scan
64
+ // after it has been placed. That happens when the caller re-renders a parent
65
+ // with its own current children reordered (drag-and-drop, sort): a match on an
66
+ // already-placed node either removes it as a tag mismatch or patches over it
67
+ // as a tag match, and either way a child the caller asked for is lost.
68
+ const placed = new Set<Node>();
60
69
  for (let i = 0; i < newChildren.length; i++) {
61
70
  const newChild = newChildren[i];
62
71
  if (newChild === undefined) {
@@ -82,7 +91,7 @@ export function reconcileChildren(parent: Node, newChildren: Node[]): void {
82
91
  // node would corrupt a node that key-matching also reuses in the same pass.
83
92
  while (oldIdx < oldChildren.length) {
84
93
  const oc = oldChildren[oldIdx];
85
- if (oc !== undefined && !nodeKey(oc)) {
94
+ if (oc !== undefined && !nodeKey(oc) && !placed.has(oc)) {
86
95
  break;
87
96
  }
88
97
  oldIdx++;
@@ -110,6 +119,7 @@ export function reconcileChildren(parent: Node, newChildren: Node[]): void {
110
119
  // keyed node. The insert and patch branches already target index i.
111
120
  const ref = parent.childNodes.item(i);
112
121
  parent.insertBefore(newChild, ref);
122
+ placed.add(newChild);
113
123
  parent.removeChild(matched);
114
124
  continue;
115
125
  }
@@ -159,12 +169,18 @@ function nodeKey(node: Node): string {
159
169
  // precedence over the generic column key (`data-col`), so an element
160
170
  // carrying both keys by its entity identity — a generic first-in-attribute-
161
171
  // order `data-col` must not shadow it.
172
+ //
173
+ // The `data-col` scan is last-wins. The DOM keys attributes by (namespace,
174
+ // localName), so `setAttributeNS` with a foreign namespace and no prefix can
175
+ // put a second attribute whose `.name` is also "data-col" on the element;
176
+ // first-wins would key the node off the foreign one and refuse to match the
177
+ // author's own, destroying and recreating the node instead of reusing it.
162
178
  let colKey = "";
163
179
  for (const attr of (node as Element).attributes) {
164
180
  if (attr.name.endsWith("-id")) {
165
181
  return `${attr.name}=${attr.value}`;
166
182
  }
167
- if (colKey === "" && attr.name === "data-col") {
183
+ if (attr.name === "data-col") {
168
184
  colKey = `${attr.name}=${attr.value}`;
169
185
  }
170
186
  }
@@ -182,8 +198,55 @@ function patchAttrs(oldEl: HTMLElement, newEl: HTMLElement): void {
182
198
  oldEl.removeAttribute(attr.name);
183
199
  }
184
200
  }
185
- if (oldEl.hidden !== newEl.hidden) {
186
- oldEl.hidden = newEl.hidden;
201
+
202
+ // Unreflected live state. `checked` (input), `selected` (option) and the
203
+ // `value` of an input/textarea are dirty-flag IDL properties: their content
204
+ // attributes are the DEFAULTS (`defaultChecked` / `defaultSelected` /
205
+ // `defaultValue`), so the two loops above equalise the default and never the
206
+ // live value. Without this, a keyed re-patch could not turn a checkbox off,
207
+ // move a selection, or replace the text in a field, ever.
208
+ //
209
+ // Syncing `value` overwrites whatever a user has typed, and it moves the caret
210
+ // to the end. That is correct only where a re-render cannot land mid-keystroke,
211
+ // which is a property of the CONSUMER, not of this function: a form re-rendered
212
+ // by a click (open, save, reset) is safe, one re-rendered by a timer or a server
213
+ // push is not. The README records the obligation; a consumer in the second shape
214
+ // must drive the field from an effect instead of re-rendering over it.
215
+ //
216
+ // Not covered here: `value` on an <option>/<progress>/<li> DOES reflect, so the
217
+ // loops above already carry it. A <select>'s value is derived from its options,
218
+ // which the OPTION branch below reconciles. `indeterminate` is unreflected too,
219
+ // but el() cannot set it, so it is outside patch()'s reach.
220
+ //
221
+ // canPatch has already established that both nodes share a nodeName, so one
222
+ // side decides the branch.
223
+ //
224
+ // Each `!==` guard is deliberate and no test here can pin it: assigning the
225
+ // same string to `.value` is a no-op through the DOM, but in a real browser it
226
+ // sets the dirty flag and moves the caret to the end, so an unconditional write
227
+ // would jump the caret on every re-render even when nothing changed. happy-dom
228
+ // exposes no caret, hence a permanently surviving `-> if (true)` mutant on each.
229
+ if (oldEl.nodeName === "INPUT") {
230
+ const oldInput = oldEl as HTMLInputElement;
231
+ const newInput = newEl as HTMLInputElement;
232
+ if (oldInput.checked !== newInput.checked) {
233
+ oldInput.checked = newInput.checked;
234
+ }
235
+ if (oldInput.value !== newInput.value) {
236
+ oldInput.value = newInput.value;
237
+ }
238
+ } else if (oldEl.nodeName === "TEXTAREA") {
239
+ const oldArea = oldEl as HTMLTextAreaElement;
240
+ const newArea = newEl as HTMLTextAreaElement;
241
+ if (oldArea.value !== newArea.value) {
242
+ oldArea.value = newArea.value;
243
+ }
244
+ } else if (oldEl.nodeName === "OPTION") {
245
+ const oldOption = oldEl as HTMLOptionElement;
246
+ const newOption = newEl as HTMLOptionElement;
247
+ if (oldOption.selected !== newOption.selected) {
248
+ oldOption.selected = newOption.selected;
249
+ }
187
250
  }
188
251
 
189
252
  // Reconcile on* event handler properties (not reflected as attributes).
package/src/signal.ts CHANGED
@@ -1,18 +1,38 @@
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
- // global epoch + per-node version fast-skip, bitfield flags.
4
- // GPL-3.0-or-later.
3
+ // per-node version fast-skip, bitfield flags.
4
+ // Apache-2.0.
5
5
  //
6
6
  // Port provenance: graph/scheduler semantics verified against
7
7
  // @preact/signals-core@1.14.4 (drift audit 2026-07-17). Deliberate deltas
8
8
  // from upstream, pinned by tests: Object.is equality incl. NaN (upstream:
9
9
  // `!==`); custom `equals` / `equals: false` options (upstream has none);
10
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.
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
+ //
16
36
  // Re-audit on upstream releases: diff src/index.ts against this file's
17
37
  // mechanisms and harvest engine-internal fixes (see CONTRIBUTING.md).
18
38
 
@@ -59,7 +79,6 @@ interface ComputedNode extends SourceNode, TargetNode {
59
79
  _isComputed: true;
60
80
  _fn: () => unknown;
61
81
  _equals: ((a: unknown, b: unknown) => boolean) | undefined;
62
- _globalVersion: number;
63
82
  }
64
83
 
65
84
  interface EffectNode {
@@ -76,7 +95,6 @@ let evalContext: TargetNode | undefined;
76
95
  let batchDepth = 0;
77
96
  let batchedEffect: EffectNode | undefined;
78
97
  let batchIteration = 0;
79
- let globalVersion = 0;
80
98
 
81
99
  // Max effect-scheduling passes within one endBatch before assuming a cyclic
82
100
  // effect-write loop and bailing with "Cycle detected".
@@ -225,7 +243,22 @@ function notifyTargets(source: SourceNode): void {
225
243
  const target = node._target;
226
244
  if (target._isComputed) {
227
245
  // It's a ComputedNode
228
- if (!(target._flags & (NOTIFIED | RUNNING))) {
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)) {
229
262
  target._flags |= DIRTY | NOTIFIED;
230
263
  notifyTargets(target as unknown as SourceNode);
231
264
  target._flags &= ~NOTIFIED;
@@ -271,12 +304,6 @@ function refreshComputed(comp: ComputedNode): void {
271
304
  throw new Error("Cycle detected");
272
305
  }
273
306
 
274
- // Fast-path: nothing changed globally since last check
275
- if (comp._globalVersion === globalVersion && !(comp._flags & DIRTY)) {
276
- return;
277
- }
278
- comp._globalVersion = globalVersion;
279
-
280
307
  const prevContext = evalContext;
281
308
  evalContext = comp;
282
309
  const prevFlags = comp._flags;
@@ -346,14 +373,31 @@ function endBatch(): void {
346
373
  if (--batchDepth > 0) {
347
374
  return;
348
375
  }
349
- let error: unknown;
350
- let hasError = false;
376
+ // The drain is re-entrant: a write, a batch() or a flushSync() from an effect
377
+ // body sees batchDepth back at 0 and starts a nested drain, and the cycle guard
378
+ // below counts on those nested passes — a two-effect ping-pong recurses one
379
+ // level per pass rather than looping here. So a nested drain must RESTORE the
380
+ // count it inherited instead of zeroing it: zeroing let any nested drain,
381
+ // including a flushSync() that found nothing to flush, wipe the passes the
382
+ // enclosing drain had accumulated and multiply the effective cap.
383
+ const enclosingIteration = batchIteration;
351
384
  while (batchedEffect !== undefined) {
352
385
  let eff: EffectNode | undefined = batchedEffect;
353
386
  batchedEffect = undefined;
354
387
  batchIteration++;
355
388
  if (batchIteration > MAX_BATCH_ITERATIONS) {
356
- batchIteration = 0;
389
+ batchIteration = enclosingIteration;
390
+ // Un-notify the chain we are dropping: notifyTargets skips a NOTIFIED
391
+ // effect, so an effect left NOTIFIED here could never be queued again —
392
+ // bailing would silently deafen every effect in flight, including
393
+ // bystanders that merely share a signal with the cycling one.
394
+ let drop: EffectNode | undefined = eff;
395
+ while (drop !== undefined) {
396
+ const nextDrop: EffectNode | undefined = drop._nextBatchedEffect;
397
+ drop._nextBatchedEffect = undefined;
398
+ drop._flags &= ~NOTIFIED;
399
+ drop = nextDrop;
400
+ }
357
401
  throw new Error("Cycle detected");
358
402
  }
359
403
  while (eff !== undefined) {
@@ -361,22 +405,12 @@ function endBatch(): void {
361
405
  eff._nextBatchedEffect = undefined;
362
406
  eff._flags &= ~NOTIFIED;
363
407
  if (!(eff._flags & DISPOSED) && needsToRecompute(eff)) {
364
- try {
365
- runEffect(eff);
366
- } catch (e) {
367
- if (!hasError) {
368
- error = e;
369
- hasError = true;
370
- }
371
- }
408
+ runEffect(eff);
372
409
  }
373
410
  eff = next;
374
411
  }
375
412
  }
376
- batchIteration = 0;
377
- if (hasError) {
378
- throw error;
379
- }
413
+ batchIteration = enclosingIteration;
380
414
  }
381
415
 
382
416
  function runCleanup(eff: EffectNode): void {
@@ -395,6 +429,13 @@ function runCleanup(eff: EffectNode): void {
395
429
  }
396
430
  }
397
431
 
432
+ // Contract: runEffect NEVER throws, and both callers depend on it.
433
+ // Its body and its cleanup each route to safeCallHandler, which swallows even a
434
+ // throwing handler; everything else here is a pointer walk over a well-formed
435
+ // list. endBatch's drain and the effect() factory therefore call it bare. A
436
+ // throw escaping this function would abandon the rest of the queued chain
437
+ // mid-drain — the same shape of failure as the cycle bail's NOTIFIED leak. If
438
+ // you add a statement here that can throw, wrap it HERE, not at the call sites.
398
439
  function runEffect(eff: EffectNode): void {
399
440
  // Run cleanup first (untracked)
400
441
  runCleanup(eff);
@@ -487,7 +528,6 @@ export function signal<T>(initial: T, options?: SignalOptions<T>): Signal<T> {
487
528
  }
488
529
  node._value = v;
489
530
  node._version++;
490
- globalVersion++;
491
531
  startBatch();
492
532
  try {
493
533
  notifyTargets(node);
@@ -519,7 +559,6 @@ export function computed<T>(fn: () => T, options?: SignalOptions<T>): ReadonlySi
519
559
  _fn: fn,
520
560
  _equals:
521
561
  eq === false ? () => false : eq ? (a: unknown, b: unknown) => eq(a as T, b as T) : undefined,
522
- _globalVersion: -1,
523
562
  };
524
563
 
525
564
  const c = {
@@ -577,11 +616,7 @@ export function effect(fn: () => Cleanup): () => void {
577
616
  };
578
617
 
579
618
  // Run immediately
580
- try {
581
- runEffect(eff);
582
- } catch (e) {
583
- safeCallHandler(e);
584
- }
619
+ runEffect(eff);
585
620
 
586
621
  return () => {
587
622
  if (eff._flags & DISPOSED) {