@cplieger/reactive 1.3.0 → 2.0.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,8 +115,7 @@ 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
120
  - `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
121
  - `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
@@ -97,15 +127,14 @@ isComputed(doubled); // true
97
127
 
98
128
  - `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
129
  - `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
130
+ - `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
131
  - `trackHandler(el, key)`: register an `on*` property for handler reconciliation during tree-diff
103
132
 
104
133
  ### Store
105
134
 
106
135
  `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
136
 
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.
137
+ - `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
138
  - `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
139
 
111
140
  ### Collections
@@ -136,7 +165,7 @@ State lives in signals; discrete events go on a bus. `createBus` is the typed ev
136
165
  - **Cycle detection**: Reading a computed that is currently being computed throws `Error("Cycle detected")`.
137
166
  - **Error caching**: If a computed's fn throws, the error is cached and rethrown on subsequent reads until dependencies change.
138
167
  - **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).
168
+ - **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
169
 
141
170
  ## Unsupported by Design
142
171
 
@@ -153,7 +182,8 @@ The following features are intentionally NOT implemented:
153
182
  | **SSR / server-side isolation** | Client-side library. Server usage should instantiate fresh signal graphs per request. |
154
183
  | **Async signals / resources** | Out of scope. Use effects + manual signal writes for async data loading. |
155
184
  | **Transactions** | Framework-level concern. Not a signals primitive. |
156
- | **Custom scheduler / `setScheduler()`** | Batch is synchronous. No scheduler needed. |
185
+ | **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). |
186
+ | **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
187
  | **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
188
  | **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
189
 
package/jsr.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/reactive",
3
- "version": "1.3.0",
3
+ "version": "2.0.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.0",
3
+ "version": "2.0.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
@@ -20,7 +20,7 @@
20
20
  "@vitest/coverage-v8": "4.1.11",
21
21
  "eslint": "10.8.1",
22
22
  "fast-check": "4.9.0",
23
- "happy-dom": "20.11.2",
23
+ "happy-dom": "20.11.6",
24
24
  "prettier": "3.9.6",
25
25
  "typescript": "npm:@typescript/typescript6@6.0.2",
26
26
  "typescript-eslint": "8.67.0",
package/src/index.ts CHANGED
@@ -2,7 +2,6 @@ export {
2
2
  signal,
3
3
  effect,
4
4
  batch,
5
- flushSync,
6
5
  setEffectErrorHandler,
7
6
  computed,
8
7
  untracked,
@@ -31,4 +30,4 @@ export { el } from "./el.js";
31
30
  export type { AttrValue } from "./el.js";
32
31
  export { reconcile, KEY_ATTR } from "./reconcile.js";
33
32
  export type { ReconcileSpec } from "./reconcile.js";
34
- export { patch, reconcileChildren, trackHandler } from "./reconcile-tree.js";
33
+ 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,104 +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
- for (let i = 0; i < newChildren.length; i++) {
61
- const newChild = newChildren[i];
62
- if (newChild === undefined) {
63
- continue;
64
- }
65
- 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;
66
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.
67
122
  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);
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
+ }
74
134
  }
75
- }
76
- } else {
77
- // Unkeyed new node: match the next UNKEYED old node by position, advancing
78
- // past keyed old nodes (which are reserved for key-based matching) and any
79
- // already-consumed slots. This mirrors the documented contract ("unkeyed
80
- // nodes match by position, skipping keyed nodes") and React/Preact's
81
- // keyed+positional reconciliation. Stopping at (and matching) a keyed old
82
- // node would corrupt a node that key-matching also reuses in the same pass.
83
- while (oldIdx < oldChildren.length) {
84
- const oc = oldChildren[oldIdx];
85
- if (oc !== undefined && !nodeKey(oc)) {
86
- 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
+ }
87
143
  }
88
- oldIdx++;
89
- }
90
- if (oldIdx < oldChildren.length) {
91
- matched = oldChildren[oldIdx] ?? null;
92
- oldIdx++;
93
144
  }
94
145
  }
95
146
 
96
- if (!matched) {
97
- const ref = parent.childNodes.item(i);
98
- parent.insertBefore(newChild, ref);
99
- continue;
100
- }
101
-
102
- if (!canPatch(matched, newChild)) {
103
- // Insert the replacement at the target index i, then drop the mismatched
104
- // old node — which may NOT be at index i. The positional scan lets an
105
- // unkeyed new node match a positionally-later unkeyed old node while an
106
- // unconsumed keyed old node sits ahead of it, so `matched` can be at a
107
- // position > i. replaceChild would leave newChild at matched's position;
108
- // a later insert then pushes it past newChildren.length and the trailing
109
- // removal loop deletes it, dropping the new node and leaving the stale
110
- // keyed node. The insert and patch branches already target index i.
111
- const ref = parent.childNodes.item(i);
112
- parent.insertBefore(newChild, ref);
113
- parent.removeChild(matched);
114
- continue;
115
- }
116
-
117
- const ref = parent.childNodes.item(i);
118
- if (ref !== matched) {
119
- parent.insertBefore(matched, ref);
120
- }
121
-
122
- if (matched.nodeType === 3) {
123
- if (matched.textContent !== newChild.textContent) {
124
- 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 and drops a text selection
153
+ // inside it, so move only when the position actually differs.
154
+ if (ref !== matched) {
155
+ parent.insertBefore(matched, ref);
125
156
  }
126
- } else if (matched.nodeType === 1) {
127
- patchAttrs(matched as HTMLElement, newChild as HTMLElement);
128
- 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;
129
177
  }
130
178
  }
131
179
 
132
- while (parent.childNodes.length > newChildren.length) {
133
- const last = parent.lastChild;
134
- if (last === null) {
135
- break;
136
- }
137
- 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;
138
188
  }
139
189
  }
140
190
 
@@ -211,11 +261,13 @@ function patchAttrs(oldEl: HTMLElement, newEl: HTMLElement): void {
211
261
  // canPatch has already established that both nodes share a nodeName, so one
212
262
  // side decides the branch.
213
263
  //
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.
264
+ // Each `!==` guard is deliberate: assigning the same string to `.value` is a
265
+ // no-op through the DOM, but in a real browser it sets the dirty flag and moves
266
+ // the caret to the end, so an unconditional write would jump the caret on every
267
+ // re-render even when nothing changed. happy-dom 20.11.6 models the dirty-value,
268
+ // dirty-checkedness and dirtiness flags, so "the re-patch did not write" is
269
+ // observable as "the element still follows its content attribute" — all four
270
+ // guards are pinned behaviourally, with no spy and no caret.
219
271
  if (oldEl.nodeName === "INPUT") {
220
272
  const oldInput = oldEl as HTMLInputElement;
221
273
  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,12 +398,20 @@ function endBatch(): void {
373
398
  if (--batchDepth > 0) {
374
399
  return;
375
400
  }
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.
408
+ const enclosingIteration = batchIteration;
376
409
  while (batchedEffect !== undefined) {
377
410
  let eff: EffectNode | undefined = batchedEffect;
378
411
  batchedEffect = undefined;
379
412
  batchIteration++;
380
413
  if (batchIteration > MAX_BATCH_ITERATIONS) {
381
- batchIteration = 0;
414
+ batchIteration = enclosingIteration;
382
415
  // Un-notify the chain we are dropping: notifyTargets skips a NOTIFIED
383
416
  // effect, so an effect left NOTIFIED here could never be queued again —
384
417
  // bailing would silently deafen every effect in flight, including
@@ -402,7 +435,7 @@ function endBatch(): void {
402
435
  eff = next;
403
436
  }
404
437
  }
405
- batchIteration = 0;
438
+ batchIteration = enclosingIteration;
406
439
  }
407
440
 
408
441
  function runCleanup(eff: EffectNode): void {
@@ -618,7 +651,8 @@ export function effect(fn: () => Cleanup): () => void {
618
651
  };
619
652
  }
620
653
 
621
- /** 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. */
622
656
  export function batch(fn: () => void): void {
623
657
  startBatch();
624
658
  try {
@@ -628,15 +662,6 @@ export function batch(fn: () => void): void {
628
662
  }
629
663
  }
630
664
 
631
- /** Flush all pending effects synchronously. No-op inside batch(). */
632
- export function flushSync(): void {
633
- if (batchDepth > 0) {
634
- return;
635
- }
636
- startBatch();
637
- endBatch();
638
- }
639
-
640
665
  /** Run fn without tracking any signal reads. */
641
666
  export function untracked<T>(fn: () => T): T {
642
667
  const prev = evalContext;
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
  }