@cplieger/reactive 1.3.1 → 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 +37 -7
- package/jsr.json +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -2
- package/src/reconcile-tree.ts +140 -98
- package/src/signal.ts +36 -19
- package/src/store.ts +18 -9
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.
|
|
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`,
|
|
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.
|
|
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
package/package.json
CHANGED
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,
|
|
33
|
+
export { patch, trackHandler } from "./reconcile-tree.js";
|
package/src/reconcile-tree.ts
CHANGED
|
@@ -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.
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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 =
|
|
95
|
+
const queue = byKey.get(key);
|
|
51
96
|
if (queue === undefined) {
|
|
52
|
-
|
|
97
|
+
byKey.set(key, [node]);
|
|
53
98
|
} else {
|
|
54
|
-
queue.push(
|
|
99
|
+
queue.push(node);
|
|
55
100
|
}
|
|
101
|
+
} else {
|
|
102
|
+
positional.push(node);
|
|
56
103
|
}
|
|
57
104
|
}
|
|
58
105
|
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
//
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
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 (
|
|
78
|
-
const
|
|
79
|
-
if (
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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 (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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 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);
|
|
135
156
|
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
|
@@ -221,11 +261,13 @@ function patchAttrs(oldEl: HTMLElement, newEl: HTMLElement): void {
|
|
|
221
261
|
// canPatch has already established that both nodes share a nodeName, so one
|
|
222
262
|
// side decides the branch.
|
|
223
263
|
//
|
|
224
|
-
// Each `!==` guard is deliberate
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
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.
|
|
229
271
|
if (oldEl.nodeName === "INPUT") {
|
|
230
272
|
const oldInput = oldEl as HTMLInputElement;
|
|
231
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).
|
|
8
|
-
//
|
|
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
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
381
|
-
//
|
|
382
|
-
//
|
|
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;
|
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,
|
|
7
|
-
//
|
|
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 '
|
|
19
|
+
// import { createStore, batch, effect } from '@cplieger/reactive';
|
|
11
20
|
// interface MyMap { count: number; name: string }
|
|
12
|
-
// const { get, set, subscribe,
|
|
21
|
+
// const { get, set, subscribe, computed } = createStore<MyMap>();
|
|
13
22
|
|
|
14
|
-
import { signal, effect,
|
|
23
|
+
import { signal, effect, untracked, type Signal } from "./signal.js";
|
|
15
24
|
|
|
16
|
-
/** A typed per-key reactive store
|
|
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,
|
|
96
|
+
return { get, set, subscribe, computed };
|
|
88
97
|
}
|