@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 +39 -8
- package/jsr.json +1 -1
- package/package.json +6 -4
- package/src/index.ts +2 -2
- package/src/reconcile-tree.ts +151 -104
- package/src/signal.ts +67 -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,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
|
-
- `
|
|
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.
|
|
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`,
|
|
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.
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/reactive",
|
|
3
|
-
"version": "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.
|
|
23
|
+
"eslint": "10.9.1",
|
|
22
24
|
"fast-check": "4.9.0",
|
|
23
|
-
"
|
|
25
|
+
"playwright": "1.62.1",
|
|
24
26
|
"prettier": "3.9.6",
|
|
25
27
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
26
|
-
"typescript-eslint": "8.
|
|
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,
|
|
34
|
+
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, so move only when the
|
|
153
|
+
// 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
|
|
|
@@ -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
|
|
210
|
-
// to the end. That is correct only where a
|
|
211
|
-
// which is a property of the CONSUMER, not
|
|
212
|
-
// by a click (open, save, reset) is safe,
|
|
213
|
-
// push is not. The README records the
|
|
214
|
-
// must drive the field from an effect
|
|
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
|
|
225
|
-
//
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
//
|
|
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).
|
|
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;
|
|
@@ -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,
|
|
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
|
}
|