@cplieger/reactive 1.3.1 → 2.1.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
@@ -25,6 +25,37 @@ The store, `SignalMap`, and `Collection` are thin facades over a single signal e
25
25
 
26
26
  Mirrors semantics from @preact/signals-core and solid-js reactivity.
27
27
 
28
+ ## Flush model
29
+
30
+ One phase, and this is the whole of it.
31
+
32
+ **A write flushes.** When `sig.value = x` returns, every effect that depends on
33
+ `sig` has already re-run. The setter notifies and drains before it returns, so
34
+ there is no pending-effect state a caller can observe and no queue to drain. Read
35
+ the DOM on the next statement and you are reading what the effects wrote. Same
36
+ contract as [@preact/signals-core](https://github.com/preactjs/signals/blob/main/packages/core/README.md),
37
+ this engine's baseline.
38
+
39
+ **`batch(fn)` is the only deferral.** Inside a batch, writes coalesce and effects
40
+ are held; they run once, synchronously, when the _outermost_ batch returns. A
41
+ nested batch defers to its outermost, and a throwing body still flushes.
42
+
43
+ **Deferral beyond that is yours.** To get effects on a later task, write on a
44
+ later task:
45
+
46
+ ```typescript
47
+ queueMicrotask(() => {
48
+ count.value = next; // effects run inside this microtask
49
+ });
50
+ ```
51
+
52
+ There is deliberately **no flush barrier** — no `flushSync()`, no `nextTick()`.
53
+ A barrier is the API a _deferred_ model owes its callers (Vue's `nextTick`,
54
+ React's `flushSync`); an eager model never holds work, so it has nothing to
55
+ report on and nothing to force. `flushSync()` existed through v1.x, could never
56
+ find work pending, and refused to act when work was pending inside a batch; it
57
+ was removed in v2.0.0. If you were calling it, delete the call — it was a no-op.
58
+
28
59
  ## Install
29
60
 
30
61
  ```sh
@@ -84,10 +115,10 @@ isComputed(doubled); // true
84
115
  - `signal<T>(initial, options?): Signal<T>`: reactive value with `.value` getter/setter and `.peek()`. Options: `{ equals?: false | ((prev, next) => boolean) }`
85
116
  - `computed<T>(fn, options?): ReadonlySignal<T>`: lazy cached derived signal (glitch-free, equality-deduped). Options: `{ equals?: false | ((prev, next) => boolean) }`
86
117
  - `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)
118
+ - `batch(fn)`: coalesce signal writes; effects flush synchronously at end of outermost batch. The only deferral — see [Flush model](#flush-model)
89
119
  - `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`.
120
+ - `touch(...signals): void`: read signals for their tracking effect alone, discarding the values — the complement of `peek()` (`peek` reads without subscribing, `touch` subscribes without keeping). `undefined` entries are skipped, so a per-key lookup that may miss (`Collection.signalFor(id)`, `SignalMap.get(id)`) can be passed straight in. Use it where a scope must re-run on a signal whose value it has no use for: most often a two-tier read, where a computed or effect takes per-entity values through `signalFor` and so must depend on the structure signal (`Collection.ids`) separately to re-derive when the id set changes. Unlike `on()` it ADDS a dependency and leaves every other read in the scope tracked
121
+ - `on(deps, fn, options?): () => U`: explicit dependency declaration helper (like Solid `on()`); pass into `effect()` or `computed()`. Its body runs untracked, so the declared deps are the scope's ONLY dependencies — reach for `touch()` instead when the rest of the scope should stay tracked. Passed to `effect()`, the body owes an explicit `return undefined` (or a cleanup function) like any effect callback, since a body with no return statement infers `() => void`, which the `Cleanup` contract rejects. With `{ defer: true }` the accessor returns `() => U | undefined`: the deferred first call yields `undefined`.
91
122
  - `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
123
  - `isSignal(value): boolean`: type guard for signals created by `signal()`
93
124
  - `isComputed(value): boolean`: type guard for computed signals
@@ -97,15 +128,14 @@ isComputed(doubled); // true
97
128
 
98
129
  - `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
130
  - `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
131
+ - `patch(parent, ...children)`: structural tree-diff, replacing a parent's children with reconciled new nodes. Correspondence between a child the parent has and a child you asked for is decided strongest-first: the same **node** (you handed back a node that is already a child of `parent` — it is placed at the index you passed it at, unmodified, so handing a parent its own children reordered is supported), then the same **key** (a `*-id` attribute, a specific entity id which takes precedence, or failing that `data-col`; duplicate-key siblings match in document order), then the same **position**. Any node that is not already a child of `parent` is a TEMPLATE: its shape may be copied into a reused child and the node itself never inserted. 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)).
102
132
  - `trackHandler(el, key)`: register an `on*` property for handler reconciliation during tree-diff
103
133
 
104
134
  ### Store
105
135
 
106
136
  `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.
107
137
 
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.
138
+ - `createStore<M>(): Store<M>`: typed, fixed-key reactive store with `get`, `set`, `subscribe`, and `computed`. A member exists here only where it does something the engine's namesake does not, so `effect` and `batch` are **not** on the store (removed in v2.0.0) — import them from the package root; `store.batch(…)` read as "batch this store's writes" while batching the whole graph. `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
139
  - `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.
110
140
 
111
141
  ### Collections
@@ -136,7 +166,7 @@ State lives in signals; discrete events go on a bus. `createBus` is the typed ev
136
166
  - **Cycle detection**: Reading a computed that is currently being computed throws `Error("Cycle detected")`.
137
167
  - **Error caching**: If a computed's fn throws, the error is cached and rethrown on subsequent reads until dependencies change.
138
168
  - **Computed is read-only**: Setting `.value` on a computed throws `Error("Cannot set a computed signal")`.
139
- - **Synchronous batch**: `batch()` flushes effects synchronously at the end of the outermost batch (matching @preact/signals-core and solid-js).
169
+ - **Synchronous batch**: `batch()` flushes effects synchronously at the end of the outermost batch (matching @preact/signals-core and solid-js). It is the only deferral the library has; a bare write flushes before it returns. See [Flush model](#flush-model).
140
170
 
141
171
  ## Unsupported by Design
142
172
 
@@ -153,7 +183,8 @@ The following features are intentionally NOT implemented:
153
183
  | **SSR / server-side isolation** | Client-side library. Server usage should instantiate fresh signal graphs per request. |
154
184
  | **Async signals / resources** | Out of scope. Use effects + manual signal writes for async data loading. |
155
185
  | **Transactions** | Framework-level concern. Not a signals primitive. |
156
- | **Custom scheduler / `setScheduler()`** | Batch is synchronous. No scheduler needed. |
186
+ | **Custom scheduler / `setScheduler()`** | Batch is synchronous and a write flushes before it returns, so there is no queue to schedule. Deferral is the caller's: write on a later task. See [Flush model](#flush-model). |
187
+ | **A flush barrier (`flushSync`, `nextTick`)** | A barrier is what a deferred model owes its callers; this model never holds work, so a barrier has nothing to report on. `flushSync()` shipped through v1.x and was a no-op from every position a caller could occupy; removed in v2.0.0. |
157
188
  | **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
189
  | **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
190
 
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/reactive",
3
- "version": "1.3.1",
3
+ "version": "2.1.0",
4
4
  "license": "Apache-2.0",
5
5
  "exports": {
6
6
  ".": "./src/index.ts"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/reactive",
3
- "version": "1.3.1",
3
+ "version": "2.1.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
@@ -17,13 +17,15 @@
17
17
  "@stryker-mutator/typescript-checker": "10.0.0",
18
18
  "@stryker-mutator/vitest-runner": "10.0.0",
19
19
  "@typescript/native": "npm:typescript@7.0.2",
20
+ "@vitest/browser": "4.1.11",
21
+ "@vitest/browser-playwright": "4.1.11",
20
22
  "@vitest/coverage-v8": "4.1.11",
21
- "eslint": "10.8.1",
23
+ "eslint": "10.9.1",
22
24
  "fast-check": "4.9.0",
23
- "happy-dom": "20.11.6",
25
+ "playwright": "1.62.1",
24
26
  "prettier": "3.9.6",
25
27
  "typescript": "npm:@typescript/typescript6@6.0.2",
26
- "typescript-eslint": "8.67.0",
28
+ "typescript-eslint": "8.69.0",
27
29
  "vitest": "4.1.11"
28
30
  },
29
31
  "overrides": {
package/src/index.ts CHANGED
@@ -2,10 +2,10 @@ export {
2
2
  signal,
3
3
  effect,
4
4
  batch,
5
- flushSync,
6
5
  setEffectErrorHandler,
7
6
  computed,
8
7
  untracked,
8
+ touch,
9
9
  subscribe,
10
10
  isSignal,
11
11
  isComputed,
@@ -31,4 +31,4 @@ export { el } from "./el.js";
31
31
  export type { AttrValue } from "./el.js";
32
32
  export { reconcile, KEY_ATTR } from "./reconcile.js";
33
33
  export type { ReconcileSpec } from "./reconcile.js";
34
- export { patch, reconcileChildren, trackHandler } from "./reconcile-tree.js";
34
+ export { patch, trackHandler } from "./reconcile-tree.js";
@@ -1,8 +1,19 @@
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: a specific `*-id` attribute
4
- // wins over the generic `data-col`; duplicate-key siblings match in
5
- // document order.
3
+ // and recursive child reconciliation.
4
+ //
5
+ // Correspondence between a child the parent HAS and a child the caller ASKED
6
+ // for is decided in one order, strongest notion of "the same child" first:
7
+ //
8
+ // 1. the same NODE — the caller handed back a node already in this parent
9
+ // 2. the same KEY — a specific `*-id` attribute wins over the generic
10
+ // `data-col`; siblings sharing a key queue in document
11
+ // order, so they pair first-with-first
12
+ // 3. the same POSITION — the next unclaimed unkeyed child
13
+ //
14
+ // Position is last because it is the only one that is not really identity. It
15
+ // is the fallback for a caller that supplies no keys, and every child is
16
+ // claimed at most once so the three tiers cannot disagree about a node.
6
17
 
7
18
  const handlerKeysMap = new WeakMap<HTMLElement, Set<string>>();
8
19
 
@@ -16,7 +27,25 @@ export function trackHandler(el: HTMLElement, key: string): void {
16
27
  keys.add(key);
17
28
  }
18
29
 
19
- /** Replace a parent's children with the given nodes/strings, reconciling against existing DOM. */
30
+ /** Replace a parent's children with the given nodes/strings, reconciling against existing DOM.
31
+ *
32
+ * Strings become text nodes, a `DocumentFragment` contributes its children,
33
+ * and `null`/`undefined` are skipped. Afterwards `parent`'s children are
34
+ * exactly what was passed, in order, with existing children reused wherever
35
+ * one corresponds to a requested child — so focus, selection, scroll position
36
+ * and live input state survive a re-render.
37
+ *
38
+ * Two things a caller has to know:
39
+ *
40
+ * - **A node that is already a child of `parent` is placed at the index it was
41
+ * passed at, unmodified.** Handing a parent its own children reordered is a
42
+ * supported operation (a drag-and-drop, a sort).
43
+ * - **Any other node is a TEMPLATE.** Its tag, attributes and content may be
44
+ * copied into a reused child and the node itself never inserted, so a
45
+ * reference to a freshly built node is not a reference to something in the
46
+ * DOM. This is what reuse costs, and it is why the point above is worth
47
+ * stating separately.
48
+ */
20
49
  export function patch(
21
50
  parent: Node,
22
51
  ...children: (string | Node | DocumentFragment | null | undefined)[]
@@ -37,114 +66,125 @@ export function patch(
37
66
  reconcileChildren(parent, newChildren);
38
67
  }
39
68
 
40
- /** Reconcile a parent node's children against a new set of child nodes, patching in place. */
69
+ /** Reconcile a parent node's children against a new set of child nodes, patching in place.
70
+ *
71
+ * @internal Not on the package's `exports` map. `patch(parent, ...nodes)` is
72
+ * the same call for a `Node[]`, with string, fragment and nullish handling on
73
+ * top.
74
+ */
41
75
  export function reconcileChildren(parent: Node, newChildren: Node[]): void {
42
- const oldChildren = Array.from(parent.childNodes);
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[]>();
47
- for (const child of oldChildren) {
48
- const key = nodeKey(child);
76
+ // A node the caller NAMED is reserved for itself. This is the one fact that
77
+ // keeps the three tiers disjoint: a node already in this parent is seated as
78
+ // itself (tier 1), so tiers 2 and 3 must never hand it to a different child.
79
+ // Without the reservation a node can be claimed twice, and the second claim
80
+ // either removes it as a tag mismatch or patches another child's content into
81
+ // it — which also means reading out of a live sibling this loop has already
82
+ // mutated.
83
+ const named = new Set<Node>(newChildren);
84
+
85
+ // Partition the current children ONCE, before any mutation, by the tier
86
+ // allowed to claim them: a keyed child only ever through its key's FIFO queue,
87
+ // an unkeyed one only ever through the positional cursor. Nothing is in both,
88
+ // so tiers 2 and 3 cannot collide, and neither can yield a node twice — a
89
+ // queue consumes by shift, the cursor only moves forward.
90
+ const byKey = new Map<string, Node[]>();
91
+ const positional: Node[] = [];
92
+ for (let node = parent.firstChild; node !== null; node = node.nextSibling) {
93
+ const key = nodeKey(node);
49
94
  if (key) {
50
- const queue = oldByKey.get(key);
95
+ const queue = byKey.get(key);
51
96
  if (queue === undefined) {
52
- oldByKey.set(key, [child]);
97
+ byKey.set(key, [node]);
53
98
  } else {
54
- queue.push(child);
99
+ queue.push(node);
55
100
  }
101
+ } else {
102
+ positional.push(node);
56
103
  }
57
104
  }
58
105
 
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>();
69
- for (let i = 0; i < newChildren.length; i++) {
70
- const newChild = newChildren[i];
71
- if (newChild === undefined) {
72
- continue;
73
- }
74
- const newKey = nodeKey(newChild);
106
+ // The node currently at the index being resolved. Every branch below seats the
107
+ // resolved child at exactly that index, so the children before it are final
108
+ // and the next reference node is the seated child's next sibling — O(1), where
109
+ // a childNodes.item(i) lookup per step is not. That invariant is also what
110
+ // makes the sweep at the end of the loop exact.
111
+ let ref: Node | null = parent.firstChild;
112
+ let cursor = 0;
75
113
 
114
+ for (const newChild of newChildren) {
115
+ // Tier 1 — the same NODE. A child the caller handed back needs no lookup and
116
+ // no reconciliation: it already IS the requested child, so it falls straight
117
+ // through to being seated as itself below. Short-circuiting the other two
118
+ // tiers is what stops a sibling being claimed as its host — with duplicate
119
+ // keys, the key queue would otherwise skip the reserved node, reach the next
120
+ // one, find it patchable on the shared tag, and let the sweep delete the node
121
+ // the caller actually named.
76
122
  let matched: Node | null = null;
77
- if (newKey) {
78
- const queue = oldByKey.get(newKey);
79
- if (queue !== undefined) {
80
- matched = queue.shift() ?? null;
81
- if (queue.length === 0) {
82
- oldByKey.delete(newKey);
123
+ if (newChild.parentNode !== parent) {
124
+ const key = nodeKey(newChild);
125
+ if (key) {
126
+ // Tier 2 — the same KEY.
127
+ const queue = byKey.get(key);
128
+ while (queue !== undefined && queue.length > 0) {
129
+ const candidate = queue.shift();
130
+ if (candidate !== undefined && !named.has(candidate)) {
131
+ matched = candidate;
132
+ break;
133
+ }
83
134
  }
84
- }
85
- } else {
86
- // Unkeyed new node: match the next UNKEYED old node by position, advancing
87
- // past keyed old nodes (which are reserved for key-based matching) and any
88
- // already-consumed slots. This mirrors the documented contract ("unkeyed
89
- // nodes match by position, skipping keyed nodes") and React/Preact's
90
- // keyed+positional reconciliation. Stopping at (and matching) a keyed old
91
- // node would corrupt a node that key-matching also reuses in the same pass.
92
- while (oldIdx < oldChildren.length) {
93
- const oc = oldChildren[oldIdx];
94
- if (oc !== undefined && !nodeKey(oc) && !placed.has(oc)) {
95
- break;
135
+ } else {
136
+ // Tier 3 — the same POSITION.
137
+ while (cursor < positional.length) {
138
+ const candidate = positional[cursor++];
139
+ if (candidate !== undefined && !named.has(candidate)) {
140
+ matched = candidate;
141
+ break;
142
+ }
96
143
  }
97
- oldIdx++;
98
- }
99
- if (oldIdx < oldChildren.length) {
100
- matched = oldChildren[oldIdx] ?? null;
101
- oldIdx++;
102
144
  }
103
145
  }
104
146
 
105
- if (!matched) {
106
- const ref = parent.childNodes.item(i);
107
- parent.insertBefore(newChild, ref);
108
- continue;
109
- }
110
-
111
- if (!canPatch(matched, newChild)) {
112
- // Insert the replacement at the target index i, then drop the mismatched
113
- // old node — which may NOT be at index i. The positional scan lets an
114
- // unkeyed new node match a positionally-later unkeyed old node while an
115
- // unconsumed keyed old node sits ahead of it, so `matched` can be at a
116
- // position > i. replaceChild would leave newChild at matched's position;
117
- // a later insert then pushes it past newChildren.length and the trailing
118
- // removal loop deletes it, dropping the new node and leaving the stale
119
- // keyed node. The insert and patch branches already target index i.
120
- const ref = parent.childNodes.item(i);
121
- parent.insertBefore(newChild, ref);
122
- placed.add(newChild);
123
- parent.removeChild(matched);
124
- continue;
125
- }
126
-
127
- const ref = parent.childNodes.item(i);
128
- if (ref !== matched) {
129
- parent.insertBefore(matched, ref);
130
- }
131
-
132
- if (matched.nodeType === 3) {
133
- if (matched.textContent !== newChild.textContent) {
134
- matched.textContent = newChild.textContent;
147
+ if (matched !== null && canPatch(matched, newChild)) {
148
+ // An existing child hosts this one: seat it, then patch it to match. The
149
+ // requested node is a template and is not inserted.
150
+ //
151
+ // Re-inserting a node that is already in place still detaches and
152
+ // reattaches it, which blurs a focused input, so move only when the
153
+ // position actually differs.
154
+ if (ref !== matched) {
155
+ parent.insertBefore(matched, ref);
135
156
  }
136
- } else if (matched.nodeType === 1) {
137
- patchAttrs(matched as HTMLElement, newChild as HTMLElement);
138
- reconcileChildren(matched, Array.from(newChild.childNodes));
157
+ if (matched.nodeType === 3) {
158
+ if (matched.textContent !== newChild.textContent) {
159
+ matched.textContent = newChild.textContent;
160
+ }
161
+ } else if (matched.nodeType === 1) {
162
+ patchAttrs(matched as HTMLElement, newChild as HTMLElement);
163
+ reconcileChildren(matched, Array.from(newChild.childNodes));
164
+ }
165
+ ref = matched.nextSibling;
166
+ } else {
167
+ // Nothing to reconcile: either the caller handed back a node this parent
168
+ // already has, or nothing corresponds to it, or the tags disagree. Either
169
+ // way the requested node itself is seated here. A child that was matched
170
+ // but not patchable is NOT removed at this point — it now sits at or after
171
+ // `ref`, was consumed so no later index can claim it, and the sweep below
172
+ // takes it. One removal path, not two.
173
+ if (ref !== newChild) {
174
+ parent.insertBefore(newChild, ref);
175
+ }
176
+ ref = newChild.nextSibling;
139
177
  }
140
178
  }
141
179
 
142
- while (parent.childNodes.length > newChildren.length) {
143
- const last = parent.lastChild;
144
- if (last === null) {
145
- break;
146
- }
147
- last.remove();
180
+ // Everything from `ref` on is a child the caller did not ask for. The
181
+ // invariant above makes that exact where comparing counts cannot be: pass one
182
+ // node twice and the requested count exceeds what a tree can hold, leaving a
183
+ // stale sibling behind.
184
+ while (ref !== null) {
185
+ const next = ref.nextSibling;
186
+ parent.removeChild(ref);
187
+ ref = next;
148
188
  }
149
189
  }
150
190
 
@@ -206,12 +246,13 @@ function patchAttrs(oldEl: HTMLElement, newEl: HTMLElement): void {
206
246
  // live value. Without this, a keyed re-patch could not turn a checkbox off,
207
247
  // move a selection, or replace the text in a field, ever.
208
248
  //
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.
249
+ // Syncing `value` overwrites whatever a user has typed, and a write that
250
+ // changes it also moves the caret to the end. That is correct only where a
251
+ // re-render cannot land mid-keystroke, which is a property of the CONSUMER, not
252
+ // of this function: a form re-rendered by a click (open, save, reset) is safe,
253
+ // one re-rendered by a timer or a server push is not. The README records the
254
+ // obligation; a consumer in the second shape must drive the field from an effect
255
+ // instead of re-rendering over it.
215
256
  //
216
257
  // Not covered here: `value` on an <option>/<progress>/<li> DOES reflect, so the
217
258
  // loops above already carry it. A <select>'s value is derived from its options,
@@ -221,11 +262,17 @@ function patchAttrs(oldEl: HTMLElement, newEl: HTMLElement): void {
221
262
  // canPatch has already established that both nodes share a nodeName, so one
222
263
  // side decides the branch.
223
264
  //
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.
265
+ // Each `!==` guard is deliberate: assigning to `.value` sets the element's
266
+ // dirty-value flag unconditionally, which severs the property from its content
267
+ // attribute for the rest of the element's life, and on a field holding raw
268
+ // content the value getter cannot see (`type=date`, `number`, `time`, `month`,
269
+ // `week`, `datetime-local`) it destroys what the user is half-way through
270
+ // typing. The caret is NOT the reason: per the HTML spec the text entry cursor
271
+ // moves only when the value actually changes, so a same-value write leaves it
272
+ // where it was. The dirty-value, dirty-checkedness and dirtiness flags are what
273
+ // make "the re-patch did not write" observable as "the element still follows
274
+ // its content attribute" — all four guards are pinned behaviourally, with no
275
+ // spy and no caret.
229
276
  if (oldEl.nodeName === "INPUT") {
230
277
  const oldInput = oldEl as HTMLInputElement;
231
278
  const newInput = newEl as HTMLInputElement;
package/src/signal.ts CHANGED
@@ -3,9 +3,34 @@
3
3
  // per-node version fast-skip, bitfield flags.
4
4
  // Apache-2.0.
5
5
  //
6
+ // FLUSH MODEL — one phase, and the whole of it:
7
+ // 1. A write flushes. When `sig.value = x` returns, every effect that depends
8
+ // on `sig` has already re-run. The setter opens an implicit batch, notifies,
9
+ // and drains before it returns, so there is no pending-effect state a caller
10
+ // can observe and no queue to drain.
11
+ // 2. `batch(fn)` is the only deferral. Writes inside it coalesce; effects are
12
+ // held and run once, synchronously, when the OUTERMOST batch returns.
13
+ // 3. Deferral beyond that is the caller's. To get effects on a later task,
14
+ // write on a later task (queueMicrotask, requestAnimationFrame). This module
15
+ // owns no scheduler and offers no flush barrier, because it never holds work.
16
+ //
17
+ // Clause 3 is why there is no `flushSync()`. A flush barrier is the API a
18
+ // DEFERRED model owes its callers — Vue's `nextTick`, React's `flushSync` — and
19
+ // an eager one owes none. The export existed until v2.0.0, could never find work
20
+ // pending (endBatch drains the moment the depth reaches 0) and refused to act
21
+ // when work WAS pending (it returned early above depth 0), so it was a no-op from
22
+ // every position a caller could occupy; six mutants over its body were
23
+ // permanently unkillable, and 71 consumer call sites had never done anything.
24
+ // Anything reintroducing a barrier here is reintroducing that: the barrier is not
25
+ // the missing piece, clause 1 is.
26
+ //
6
27
  // 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:
28
+ // @preact/signals-core@1.14.4 (drift audit 2026-07-17). The flush model above is
29
+ // upstream's, including the absence of a barrier: upstream documents the write
30
+ // contract in prose ("Changing a signal's value synchronously updates every
31
+ // computed and effect that depends on that signal") and its surface is
32
+ // signal/computed/effect/batch/untracked/createModel. Deliberate deltas from
33
+ // upstream, pinned by tests: Object.is equality incl. NaN (upstream:
9
34
  // `!==`); custom `equals` / `equals: false` options (upstream has none);
10
35
  // isolate-and-continue effect error policy via setEffectErrorHandler
11
36
  // (upstream disposes-and-rethrows); the cycle guard lives in endBatch's drain
@@ -373,13 +398,13 @@ function endBatch(): void {
373
398
  if (--batchDepth > 0) {
374
399
  return;
375
400
  }
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.
401
+ // The drain is re-entrant: a write or a batch() from an effect body sees
402
+ // batchDepth back at 0 and starts a nested drain, and the cycle guard below
403
+ // counts on those nested passes — a two-effect ping-pong recurses one level per
404
+ // pass rather than looping here. So a nested drain must RESTORE the count it
405
+ // inherited instead of zeroing it: zeroing let any nested drain, including an
406
+ // empty `batch(() => {})`, wipe the passes the enclosing drain had accumulated
407
+ // and multiply the effective cap.
383
408
  const enclosingIteration = batchIteration;
384
409
  while (batchedEffect !== undefined) {
385
410
  let eff: EffectNode | undefined = batchedEffect;
@@ -626,7 +651,8 @@ export function effect(fn: () => Cleanup): () => void {
626
651
  };
627
652
  }
628
653
 
629
- /** Coalesce signal writes; effects flush synchronously at end of outermost batch. */
654
+ /** Coalesce signal writes; effects flush synchronously at end of outermost batch.
655
+ * This is the library's ONLY deferral: see the flush model in the module header. */
630
656
  export function batch(fn: () => void): void {
631
657
  startBatch();
632
658
  try {
@@ -636,15 +662,6 @@ export function batch(fn: () => void): void {
636
662
  }
637
663
  }
638
664
 
639
- /** Flush all pending effects synchronously. No-op inside batch(). */
640
- export function flushSync(): void {
641
- if (batchDepth > 0) {
642
- return;
643
- }
644
- startBatch();
645
- endBatch();
646
- }
647
-
648
665
  /** Run fn without tracking any signal reads. */
649
666
  export function untracked<T>(fn: () => T): T {
650
667
  const prev = evalContext;
@@ -656,6 +673,37 @@ export function untracked<T>(fn: () => T): T {
656
673
  }
657
674
  }
658
675
 
676
+ /** Read a signal for its tracking effect alone, discarding the value.
677
+ *
678
+ * The complement of `peek()`: `peek` reads without subscribing, `touch`
679
+ * subscribes without keeping. Outside a reactive scope it does nothing
680
+ * observable.
681
+ *
682
+ * This exists because the two-tier shape `createCollection` and `bindList` are
683
+ * built on produces it: a scope reads per-entity values through `signalFor`,
684
+ * so it must depend on the structure signal separately to re-run when the id
685
+ * set changes, and that dependency has no value the body wants. Spelling it as
686
+ * a discarded expression left the intent to a comment, and `undefined` entries
687
+ * are accepted so a per-key lookup that may miss (`signalFor(id)`,
688
+ * `SignalMap.get(id)`) can be passed straight in.
689
+ *
690
+ * Not a substitute for `on()`, which declares an exhaustive dependency list
691
+ * and runs its body untracked. `touch` ADDS a dependency and leaves every
692
+ * other read in the scope tracked as usual. */
693
+ export function touch(
694
+ ...signals: readonly (Signal<unknown> | ReadonlySignal<unknown> | undefined)[]
695
+ ): void {
696
+ for (const sig of signals) {
697
+ readForDependency(sig);
698
+ }
699
+ }
700
+
701
+ // Named so the read is a call rather than a discarded expression: the value is
702
+ // deliberately unused, and returning it is what keeps that legible.
703
+ function readForDependency(sig: Signal<unknown> | ReadonlySignal<unknown> | undefined): unknown {
704
+ return sig?.value;
705
+ }
706
+
659
707
  /** Subscribe to a signal, calling cb on every change. Returns dispose function.
660
708
  * The callback runs untracked: reading other signals inside it does not add
661
709
  * them as dependencies of the subscription (matches upstream
package/src/store.ts CHANGED
@@ -3,22 +3,32 @@
3
3
  // Each key is backed by a lazily-created signal, so the store inherits the
4
4
  // engine's glitch-freedom, cycle detection, batching, and error handling.
5
5
  // There is NO separate reactivity implementation here: get/set read and
6
- // write signals, effect/batch are the engine's own, and auto-tracking falls
7
- // out of reading `signal.value` inside an effect.
6
+ // write signals, and auto-tracking falls out of reading `signal.value` inside an
7
+ // effect.
8
+ //
9
+ // The member set is decided by one rule: a Store member exists only where it
10
+ // does something the engine's namesake does NOT. `subscribe` skips the initial
11
+ // call and runs untracked; `computed` is an eager key-writing effect rather than
12
+ // a lazy signal. `effect` and `batch` were the engine's own function objects
13
+ // verbatim and are NOT re-exposed here (removed in v2.0.0) — `store.batch(…)`
14
+ // read as "batch this store's writes" while batching the whole graph, which is a
15
+ // scope the flush model does not have. Import `batch` and `effect` from the
16
+ // package root beside `createStore`.
8
17
  //
9
18
  // Usage:
10
- // import { createStore } from './store.js';
19
+ // import { createStore, batch, effect } from '@cplieger/reactive';
11
20
  // interface MyMap { count: number; name: string }
12
- // const { get, set, subscribe, effect, computed, batch } = createStore<MyMap>();
21
+ // const { get, set, subscribe, computed } = createStore<MyMap>();
13
22
 
14
- import { signal, effect, batch, untracked, type Signal, type Cleanup } from "./signal.js";
23
+ import { signal, effect, untracked, type Signal } from "./signal.js";
15
24
 
16
- /** A typed per-key reactive store with auto-tracked effects, computed, and batching. */
25
+ /** A typed per-key reactive store: lazily signal-backed keys, change-only
26
+ * subscriptions, and eager derived keys. Effects and batching are the engine's
27
+ * own `effect` / `batch`, imported from the package root. */
17
28
  export interface Store<M> {
18
29
  get<K extends keyof M & string>(key: K): M[K];
19
30
  set<K extends keyof M & string>(key: K, value: M[K]): void;
20
31
  subscribe<K extends keyof M & string>(key: K, cb: (value: M[K]) => void): () => void;
21
- effect(fn: () => Cleanup): () => void;
22
32
  /** Derive `outputKey` from other keys. NOT a lazy engine `computed`: this is
23
33
  * an EAGER effect that re-runs `fn` on dependency change and WRITES the
24
34
  * result to `outputKey`. Consequences of that shape: `fn` runs whether or
@@ -28,7 +38,6 @@ export interface Store<M> {
28
38
  * self-reading `fn` behaves as documented on the implementation below.
29
39
  * Returns the effect's dispose function. */
30
40
  computed<K extends keyof M & string>(outputKey: K, fn: () => M[K]): () => void;
31
- batch(fn: () => void): void;
32
41
  }
33
42
 
34
43
  /** Create a typed reactive store. Keys are lazily backed by signals; reading a
@@ -84,5 +93,5 @@ export function createStore<M>(): Store<M> {
84
93
  });
85
94
  }
86
95
 
87
- return { get, set, subscribe, effect, computed, batch };
96
+ return { get, set, subscribe, computed };
88
97
  }