@cplieger/reactive 1.2.5 → 1.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +201 -692
- package/README.md +64 -45
- package/jsr.json +3 -3
- package/package.json +33 -15
- package/src/reconcile-tree.ts +67 -4
- package/src/signal.ts +73 -38
package/README.md
CHANGED
|
@@ -9,7 +9,19 @@
|
|
|
9
9
|
|
|
10
10
|
> Signals + collections + DOM-reconciliation micro-framework for TypeScript
|
|
11
11
|
|
|
12
|
-
A standalone reactive app-skeleton:
|
|
12
|
+
A standalone reactive app-skeleton:
|
|
13
|
+
|
|
14
|
+
- fine-grained signals with automatic dependency tracking and synchronous batched effects
|
|
15
|
+
- a typed per-key reactive store
|
|
16
|
+
- a dynamic per-id signal registry (`SignalMap`)
|
|
17
|
+
- a reactive ordered keyed `Collection`
|
|
18
|
+
- a two-tier list render binding (`bindList`)
|
|
19
|
+
- a CSP-safe element factory (`el`)
|
|
20
|
+
- keyed-list DOM reconciliation
|
|
21
|
+
- structural tree-diffing
|
|
22
|
+
- a typed event bus (`createBus`)
|
|
23
|
+
|
|
24
|
+
The store, `SignalMap`, and `Collection` are thin facades over a single signal engine: one reactive core, not several. Zero dependencies beyond the DOM API.
|
|
13
25
|
|
|
14
26
|
Mirrors semantics from @preact/signals-core and solid-js reactivity.
|
|
15
27
|
|
|
@@ -69,86 +81,93 @@ isComputed(doubled); // true
|
|
|
69
81
|
|
|
70
82
|
### Signals
|
|
71
83
|
|
|
72
|
-
- `signal<T>(initial, options?): Signal<T
|
|
73
|
-
- `computed<T>(fn, options?): ReadonlySignal<T
|
|
74
|
-
- `effect(fn: () => Cleanup): () => void
|
|
75
|
-
- `batch(fn)
|
|
76
|
-
- `flushSync()
|
|
77
|
-
- `untracked<T>(fn): T
|
|
78
|
-
- `on(deps, fn, options?): () => U
|
|
79
|
-
- `subscribe(signal, cb): () => void
|
|
80
|
-
- `isSignal(value): boolean
|
|
81
|
-
- `isComputed(value): boolean
|
|
82
|
-
- `setEffectErrorHandler(handler): EffectErrorHandler
|
|
84
|
+
- `signal<T>(initial, options?): Signal<T>`: reactive value with `.value` getter/setter and `.peek()`. Options: `{ equals?: false | ((prev, next) => boolean) }`
|
|
85
|
+
- `computed<T>(fn, options?): ReadonlySignal<T>`: lazy cached derived signal (glitch-free, equality-deduped). Options: `{ equals?: false | ((prev, next) => boolean) }`
|
|
86
|
+
- `effect(fn: () => Cleanup): () => void`: auto-tracking side-effect with cleanup support. `fn` must return `undefined` or a cleanup function.
|
|
87
|
+
- `batch(fn)`: coalesce signal writes; effects flush synchronously at end of outermost batch
|
|
88
|
+
- `flushSync()`: synchronously drain pending effects (no-op inside batch)
|
|
89
|
+
- `untracked<T>(fn): T`: run fn without tracking signal reads (like Preact `untracked()` / Solid `untrack()`)
|
|
90
|
+
- `on(deps, fn, options?): () => U`: explicit dependency declaration helper (like Solid `on()`); pass into `effect()` or `computed()`. With `{ defer: true }` the accessor returns `() => U | undefined`: the deferred first call yields `undefined`.
|
|
91
|
+
- `subscribe(signal, cb): () => void`: subscribe to a signal, calling cb immediately and on every change. The callback runs untracked: signals read inside it do not become dependencies of the subscription
|
|
92
|
+
- `isSignal(value): boolean`: type guard for signals created by `signal()`
|
|
93
|
+
- `isComputed(value): boolean`: type guard for computed signals
|
|
94
|
+
- `setEffectErrorHandler(handler): EffectErrorHandler`: set global error handler for effects; returns the previous handler
|
|
83
95
|
|
|
84
96
|
### DOM building & reconciliation
|
|
85
97
|
|
|
86
|
-
- `el(tag, attrs?, ...children)
|
|
87
|
-
- `reconcile<T>(parent, items, spec)
|
|
88
|
-
- `patch(parent, ...children)
|
|
89
|
-
- `reconcileChildren(parent, newChildren)
|
|
90
|
-
- `trackHandler(el, key)
|
|
98
|
+
- `el(tag, attrs?, ...children)`: CSP-safe element factory (the build half; `reconcile`/`patch` are the commit half). `className` → class; `on*` → handler property + `trackHandler` (so `patch` reconciles handlers); boolean DOM props (`hidden`/`disabled`/`checked`/`selected`/…) and `value`/`colSpan`/`rowSpan`/`tabIndex`/`htmlFor` → properties; everything else (`data-*`, `aria-*`, `style`, `id`) → `setAttribute`. String children become text nodes (never parsed as HTML); null/undefined attrs and children are skipped.
|
|
99
|
+
- `reconcile<T>(parent, items, spec)`: keyed-list DOM reconciliation with mount/update/onRemove lifecycle
|
|
100
|
+
- `patch(parent, ...children)`: structural tree-diff, replacing a parent's children with reconciled new nodes. Element nodes are keyed by a `*-id` attribute (a specific entity id, which takes precedence) or, failing that, `data-col`, so reorders and re-patches reuse the matched node; duplicate-key siblings match in document order; unkeyed nodes match by position. A reused element takes the new node's attributes, its tracked `on*` handlers, and the unreflected live properties the attribute pass cannot reach: `checked` (input), `selected` (option) and the `value` of an `input`/`textarea`. Those reflect to `defaultChecked`/`defaultSelected`/`defaultValue`, so without this a re-render could never turn a checkbox off or replace the text in a field. Syncing `value` overwrites what a user has typed and moves the caret to the end, which is correct only where a re-render cannot land mid-keystroke: a form re-rendered by a click is safe, one re-rendered by a timer or a server push is not, and that shape must drive the field from an effect instead (see [Unsupported by Design](#unsupported-by-design)).
|
|
101
|
+
- `reconcileChildren(parent, newChildren)`: low-level child reconciliation against existing DOM
|
|
102
|
+
- `trackHandler(el, key)`: register an `on*` property for handler reconciliation during tree-diff
|
|
91
103
|
|
|
92
104
|
### Store
|
|
93
105
|
|
|
94
|
-
`createStore` and `SignalMap` are thin facades over the one signal engine
|
|
106
|
+
`createStore` and `SignalMap` are thin facades over the one signal engine. `createStore` lazily backs each key with a signal; `SignalMap` is a registry of signals keyed by a runtime string id.
|
|
95
107
|
|
|
96
|
-
- `createStore<M>(): Store<M
|
|
97
|
-
- `SignalMap<V
|
|
108
|
+
- `createStore<M>(): Store<M>`: typed, fixed-key reactive store with `get`, `set`, `subscribe`, `effect`, `computed`, and `batch`. `subscribe` notifies on change only (not immediately on subscribe), and its callback runs untracked. `computed(outputKey, fn)` is an eager effect, not a lazy engine computed: it writes `outputKey` on every dependency change. So `fn` runs whether or not anyone reads the key; a throwing `fn` routes to the effect error handler instead of being cached and rethrown at the read site; `set(outputKey, …)` still works between recomputes. A `computed` key whose fn reads its own output does not loop unbounded: a self-read that keeps yielding new values surfaces `Error("Cycle detected")` through the effect error handler, while a stable self-read settles. The guard re-arms per write: a key that is still cyclic reports again on the next write to it rather than reporting once and going quiet, and effects merely queued alongside the cycle (a `subscribe` on the same key, say) stay reactive.
|
|
109
|
+
- `SignalMap<V>`: dynamic per-id signal registry: `get(id)`, `ensure(id, initial)`, `clear(id)`, `clearAll()`. For reactive state whose key set isn't known at the type level (per-message streaming text, per-row state, …); complements `createStore`'s fixed key set.
|
|
98
110
|
|
|
99
111
|
### Collections
|
|
100
112
|
|
|
101
|
-
A reactive ordered collection of keyed entities
|
|
113
|
+
A reactive ordered collection of keyed entities: the data half of the two-tier list pattern. A per-entity content update touches only that entity's subscribers; add/remove/reorder bumps the structure signal (`ids`). Built on `signal` + `SignalMap`.
|
|
102
114
|
|
|
103
|
-
- `createCollection<T>(keyOf: (item: T) => string): Collection<T
|
|
104
|
-
- `setAll(items)
|
|
105
|
-
- `upsert(item)
|
|
115
|
+
- `createCollection<T>(keyOf: (item: T) => string): Collection<T>`: returns a collection with:
|
|
116
|
+
- `setAll(items)`: replace everything (same-order replacement does not bump `ids`; duplicate keys deduplicate: first occurrence keeps the position, last value wins)
|
|
117
|
+
- `upsert(item)`: add/replace one (appends new ids); `prepend(items)`: add to the front (scroll-up/load-older pagination)
|
|
106
118
|
- `update(id, next | updater)` / `remove(id)` / `clear()`
|
|
107
119
|
- `get(id)` / `has(id)` (untracked), `signalFor(id)` (reactive per-entity), `size`
|
|
108
|
-
- `ids: ReadonlySignal<readonly string[]
|
|
109
|
-
- `items()
|
|
120
|
+
- `ids: ReadonlySignal<readonly string[]>`: structure tier (add/remove/reorder only)
|
|
121
|
+
- `items()`: ordered reactive snapshot (tracks order + every entity)
|
|
110
122
|
|
|
111
123
|
### List rendering
|
|
112
124
|
|
|
113
|
-
- `bindList<T>(parent, source, spec): () => void
|
|
125
|
+
- `bindList<T>(parent, source, spec): () => void`: two-tier render binding. `source` is a `ListSource<T>` = `{ ids, signalFor }`; a `Collection` satisfies it directly, and a filtered/sorted/paginated view is just `{ ids: computed(...), signalFor: collection.signalFor }` (pagination is a sliced view, not a separate primitive). A structural effect tracks `source.ids` and reconciles the row list; each row's own effect tracks only its entity signal, so a per-entity change repaints just that row with no structural reconcile. An id whose `signalFor` is `undefined` (an inconsistent source) is skipped rather than rendered. `spec`: `{ mount(item, id) => HTMLElement; update?(el, item, id); onRemove?(el, id) }` (`update` runs at mount and on every later change). Returns a dispose that tears down the structural effect and every row effect.
|
|
114
126
|
|
|
115
127
|
### Event bus
|
|
116
128
|
|
|
117
|
-
State lives in signals; discrete events go on a bus. `createBus` is the typed event primitive (not reactive state
|
|
129
|
+
State lives in signals; discrete events go on a bus. `createBus` is the typed event primitive (not reactive state; no retained value).
|
|
118
130
|
|
|
119
|
-
- `createBus<EventMap>(options?): Bus<EventMap
|
|
131
|
+
- `createBus<EventMap>(options?): Bus<EventMap>`: `on`/`once`/`off`/`emit`/`clear`. Handlers are snapshot-cached (rebuilt only on mutation; a handler unsubscribed mid-emit still fires for that emit). Events whose payload type is `undefined` emit with no payload argument. A throwing handler is isolated via `options.onError` (default `console.error`).
|
|
120
132
|
|
|
121
133
|
## Correctness guarantees
|
|
122
134
|
|
|
123
|
-
- **Glitch-freedom**: Computed signals use equality dedup (`Object.is`)
|
|
135
|
+
- **Glitch-freedom**: Computed signals use equality dedup (`Object.is`), so downstream effects only fire when the computed value actually changes. Diamond dependency graphs are handled correctly.
|
|
124
136
|
- **Cycle detection**: Reading a computed that is currently being computed throws `Error("Cycle detected")`.
|
|
125
137
|
- **Error caching**: If a computed's fn throws, the error is cached and rethrown on subsequent reads until dependencies change.
|
|
126
138
|
- **Computed is read-only**: Setting `.value` on a computed throws `Error("Cannot set a computed signal")`.
|
|
127
139
|
- **Synchronous batch**: `batch()` flushes effects synchronously at the end of the outermost batch (matching @preact/signals-core and solid-js).
|
|
128
140
|
|
|
129
|
-
##
|
|
141
|
+
## Unsupported by Design
|
|
130
142
|
|
|
131
143
|
The following features are intentionally NOT implemented:
|
|
132
144
|
|
|
133
|
-
| Feature
|
|
134
|
-
|
|
|
135
|
-
| **Effect ownership tree / `createRoot`**
|
|
136
|
-
| **Nested effect auto-disposal**
|
|
137
|
-
| **Lazy activation / `onMount` lifecycle**
|
|
138
|
-
| **`Signal.subtle.Watcher` / notify-on-dirty**
|
|
139
|
-
| **Introspection APIs**
|
|
140
|
-
| **Explicit computed disposal**
|
|
141
|
-
| **SSR / server-side isolation**
|
|
142
|
-
| **Async signals / resources**
|
|
143
|
-
| **Transactions**
|
|
144
|
-
| **Custom scheduler / `setScheduler()`**
|
|
145
|
+
| Feature | Reason |
|
|
146
|
+
| ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
147
|
+
| **Effect ownership tree / `createRoot`** | Consumers manage disposal explicitly. Adding ownership changes the mental model. |
|
|
148
|
+
| **Nested effect auto-disposal** | Each effect is independent and returns its own dispose function. Compose with arrays or helper functions. |
|
|
149
|
+
| **Lazy activation / `onMount` lifecycle** | Signals are always active. Resource management is the consumer's responsibility. |
|
|
150
|
+
| **`Signal.subtle.Watcher` / notify-on-dirty** | This library IS the framework layer. The Watcher pattern is for frameworks that sit on top of a signals primitive. |
|
|
151
|
+
| **Introspection APIs** | Dev-tools concern. Not needed for production. |
|
|
152
|
+
| **Explicit computed disposal** | Computed signals are GC'd when unreferenced. No explicit teardown needed. |
|
|
153
|
+
| **SSR / server-side isolation** | Client-side library. Server usage should instantiate fresh signal graphs per request. |
|
|
154
|
+
| **Async signals / resources** | Out of scope. Use effects + manual signal writes for async data loading. |
|
|
155
|
+
| **Transactions** | Framework-level concern. Not a signals primitive. |
|
|
156
|
+
| **Custom scheduler / `setScheduler()`** | Batch is synchronous. No scheduler needed. |
|
|
157
|
+
| **Controlled form inputs** | `patch()` syncs `checked`, `selected` and `input`/`textarea` `value`, so the render owns them. It has no change routing or state ownership, so a surface re-rendered while a user types must own the field itself. |
|
|
158
|
+
| **Waking subscribers of a self-writing computed** | A computed that writes a signal it reads is re-read correctly (it is marked stale so the next read re-runs it), but its own subscribers are not woken, so an effect depending on it does not re-run until something else notifies it. Writing a source from a computed body is outside the intended shape; do it in an effect. |
|
|
159
|
+
|
|
160
|
+
## Contributing
|
|
161
|
+
|
|
162
|
+
Issues and PRs are welcome. See [CONTRIBUTING.md](CONTRIBUTING.md) for the
|
|
163
|
+
conventions and how to run the checks locally.
|
|
145
164
|
|
|
146
165
|
## Disclaimer
|
|
147
166
|
|
|
148
167
|
This project is built with care and follows security best practices, but it is intended for personal / self-hosted use. No guarantees of fitness for production environments. Use at your own risk.
|
|
149
168
|
|
|
150
|
-
This project was built with AI-assisted tooling using [Claude
|
|
169
|
+
This project was built with AI-assisted tooling using [Claude](https://claude.com), [GPT](https://openai.com), and [Kiro](https://kiro.dev). The human maintainer defines architecture, supervises implementation, and makes all final decisions.
|
|
151
170
|
|
|
152
171
|
## License
|
|
153
172
|
|
|
154
|
-
|
|
173
|
+
Apache-2.0. See [LICENSE](LICENSE).
|
package/jsr.json
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/reactive",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
|
+
"license": "Apache-2.0",
|
|
4
5
|
"exports": {
|
|
5
6
|
".": "./src/index.ts"
|
|
6
7
|
},
|
|
@@ -11,8 +12,7 @@
|
|
|
11
12
|
"src/**/*.ts"
|
|
12
13
|
],
|
|
13
14
|
"exclude": [
|
|
14
|
-
"src/**/*.test.ts"
|
|
15
|
-
"src/**/__test-helpers__/**"
|
|
15
|
+
"src/**/*.test.ts"
|
|
16
16
|
]
|
|
17
17
|
}
|
|
18
18
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/reactive",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"exports": {
|
|
@@ -13,21 +13,24 @@
|
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"@eslint/js": "10.0.1",
|
|
16
|
-
"@stryker-mutator/core": "
|
|
17
|
-
"@stryker-mutator/typescript-checker": "
|
|
18
|
-
"@stryker-mutator/vitest-runner": "
|
|
16
|
+
"@stryker-mutator/core": "10.0.0",
|
|
17
|
+
"@stryker-mutator/typescript-checker": "10.0.0",
|
|
18
|
+
"@stryker-mutator/vitest-runner": "10.0.0",
|
|
19
19
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
20
|
-
"@vitest/coverage-v8": "4.1.
|
|
21
|
-
"eslint": "10.
|
|
20
|
+
"@vitest/coverage-v8": "4.1.11",
|
|
21
|
+
"eslint": "10.8.1",
|
|
22
22
|
"fast-check": "4.9.0",
|
|
23
|
-
"happy-dom": "20.
|
|
24
|
-
"prettier": "3.9.
|
|
23
|
+
"happy-dom": "20.11.6",
|
|
24
|
+
"prettier": "3.9.6",
|
|
25
25
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
26
|
-
"typescript-eslint": "8.
|
|
27
|
-
"vitest": "4.1.
|
|
26
|
+
"typescript-eslint": "8.67.0",
|
|
27
|
+
"vitest": "4.1.11"
|
|
28
|
+
},
|
|
29
|
+
"overrides": {
|
|
30
|
+
"qs": "^6.15.2"
|
|
28
31
|
},
|
|
29
32
|
"description": "Signals + DOM-reconciliation reactive core for TypeScript (zero dependencies)",
|
|
30
|
-
"license": "
|
|
33
|
+
"license": "Apache-2.0",
|
|
31
34
|
"types": "./src/index.ts",
|
|
32
35
|
"publishConfig": {
|
|
33
36
|
"access": "public"
|
|
@@ -36,11 +39,26 @@
|
|
|
36
39
|
"type": "git",
|
|
37
40
|
"url": "git+https://github.com/cplieger/reactive.git"
|
|
38
41
|
},
|
|
42
|
+
"homepage": "https://github.com/cplieger/reactive",
|
|
43
|
+
"bugs": {
|
|
44
|
+
"url": "https://github.com/cplieger/reactive/issues"
|
|
45
|
+
},
|
|
46
|
+
"author": "cplieger <917744+cplieger@users.noreply.github.com>",
|
|
47
|
+
"keywords": [
|
|
48
|
+
"signals",
|
|
49
|
+
"reactivity",
|
|
50
|
+
"computed",
|
|
51
|
+
"effect",
|
|
52
|
+
"dom",
|
|
53
|
+
"reconciliation",
|
|
54
|
+
"store",
|
|
55
|
+
"event-bus",
|
|
56
|
+
"zero-dependency",
|
|
57
|
+
"typescript"
|
|
58
|
+
],
|
|
39
59
|
"files": [
|
|
40
60
|
"src/",
|
|
41
|
-
"
|
|
42
|
-
"!src
|
|
43
|
-
"!src/**/fc-strict-setup.ts",
|
|
44
|
-
"jsr.json"
|
|
61
|
+
"jsr.json",
|
|
62
|
+
"!src/**/*.test.ts"
|
|
45
63
|
]
|
|
46
64
|
}
|
package/src/reconcile-tree.ts
CHANGED
|
@@ -57,6 +57,15 @@ export function reconcileChildren(parent: Node, newChildren: Node[]): void {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
let oldIdx = 0;
|
|
60
|
+
// Old nodes already placed as the resolved child for an earlier index. The
|
|
61
|
+
// keyed path cannot hand one back — each key is consumed from its FIFO queue —
|
|
62
|
+
// but `oldChildren` is a SNAPSHOT taken before the loop, so a node still
|
|
63
|
+
// occupying its original slot there can be handed back by the positional scan
|
|
64
|
+
// after it has been placed. That happens when the caller re-renders a parent
|
|
65
|
+
// with its own current children reordered (drag-and-drop, sort): a match on an
|
|
66
|
+
// already-placed node either removes it as a tag mismatch or patches over it
|
|
67
|
+
// as a tag match, and either way a child the caller asked for is lost.
|
|
68
|
+
const placed = new Set<Node>();
|
|
60
69
|
for (let i = 0; i < newChildren.length; i++) {
|
|
61
70
|
const newChild = newChildren[i];
|
|
62
71
|
if (newChild === undefined) {
|
|
@@ -82,7 +91,7 @@ export function reconcileChildren(parent: Node, newChildren: Node[]): void {
|
|
|
82
91
|
// node would corrupt a node that key-matching also reuses in the same pass.
|
|
83
92
|
while (oldIdx < oldChildren.length) {
|
|
84
93
|
const oc = oldChildren[oldIdx];
|
|
85
|
-
if (oc !== undefined && !nodeKey(oc)) {
|
|
94
|
+
if (oc !== undefined && !nodeKey(oc) && !placed.has(oc)) {
|
|
86
95
|
break;
|
|
87
96
|
}
|
|
88
97
|
oldIdx++;
|
|
@@ -110,6 +119,7 @@ export function reconcileChildren(parent: Node, newChildren: Node[]): void {
|
|
|
110
119
|
// keyed node. The insert and patch branches already target index i.
|
|
111
120
|
const ref = parent.childNodes.item(i);
|
|
112
121
|
parent.insertBefore(newChild, ref);
|
|
122
|
+
placed.add(newChild);
|
|
113
123
|
parent.removeChild(matched);
|
|
114
124
|
continue;
|
|
115
125
|
}
|
|
@@ -159,12 +169,18 @@ function nodeKey(node: Node): string {
|
|
|
159
169
|
// precedence over the generic column key (`data-col`), so an element
|
|
160
170
|
// carrying both keys by its entity identity — a generic first-in-attribute-
|
|
161
171
|
// order `data-col` must not shadow it.
|
|
172
|
+
//
|
|
173
|
+
// The `data-col` scan is last-wins. The DOM keys attributes by (namespace,
|
|
174
|
+
// localName), so `setAttributeNS` with a foreign namespace and no prefix can
|
|
175
|
+
// put a second attribute whose `.name` is also "data-col" on the element;
|
|
176
|
+
// first-wins would key the node off the foreign one and refuse to match the
|
|
177
|
+
// author's own, destroying and recreating the node instead of reusing it.
|
|
162
178
|
let colKey = "";
|
|
163
179
|
for (const attr of (node as Element).attributes) {
|
|
164
180
|
if (attr.name.endsWith("-id")) {
|
|
165
181
|
return `${attr.name}=${attr.value}`;
|
|
166
182
|
}
|
|
167
|
-
if (
|
|
183
|
+
if (attr.name === "data-col") {
|
|
168
184
|
colKey = `${attr.name}=${attr.value}`;
|
|
169
185
|
}
|
|
170
186
|
}
|
|
@@ -182,8 +198,55 @@ function patchAttrs(oldEl: HTMLElement, newEl: HTMLElement): void {
|
|
|
182
198
|
oldEl.removeAttribute(attr.name);
|
|
183
199
|
}
|
|
184
200
|
}
|
|
185
|
-
|
|
186
|
-
|
|
201
|
+
|
|
202
|
+
// Unreflected live state. `checked` (input), `selected` (option) and the
|
|
203
|
+
// `value` of an input/textarea are dirty-flag IDL properties: their content
|
|
204
|
+
// attributes are the DEFAULTS (`defaultChecked` / `defaultSelected` /
|
|
205
|
+
// `defaultValue`), so the two loops above equalise the default and never the
|
|
206
|
+
// live value. Without this, a keyed re-patch could not turn a checkbox off,
|
|
207
|
+
// move a selection, or replace the text in a field, ever.
|
|
208
|
+
//
|
|
209
|
+
// Syncing `value` overwrites whatever a user has typed, and it moves the caret
|
|
210
|
+
// to the end. That is correct only where a re-render cannot land mid-keystroke,
|
|
211
|
+
// which is a property of the CONSUMER, not of this function: a form re-rendered
|
|
212
|
+
// by a click (open, save, reset) is safe, one re-rendered by a timer or a server
|
|
213
|
+
// push is not. The README records the obligation; a consumer in the second shape
|
|
214
|
+
// must drive the field from an effect instead of re-rendering over it.
|
|
215
|
+
//
|
|
216
|
+
// Not covered here: `value` on an <option>/<progress>/<li> DOES reflect, so the
|
|
217
|
+
// loops above already carry it. A <select>'s value is derived from its options,
|
|
218
|
+
// which the OPTION branch below reconciles. `indeterminate` is unreflected too,
|
|
219
|
+
// but el() cannot set it, so it is outside patch()'s reach.
|
|
220
|
+
//
|
|
221
|
+
// canPatch has already established that both nodes share a nodeName, so one
|
|
222
|
+
// side decides the branch.
|
|
223
|
+
//
|
|
224
|
+
// Each `!==` guard is deliberate and no test here can pin it: assigning the
|
|
225
|
+
// same string to `.value` is a no-op through the DOM, but in a real browser it
|
|
226
|
+
// sets the dirty flag and moves the caret to the end, so an unconditional write
|
|
227
|
+
// would jump the caret on every re-render even when nothing changed. happy-dom
|
|
228
|
+
// exposes no caret, hence a permanently surviving `-> if (true)` mutant on each.
|
|
229
|
+
if (oldEl.nodeName === "INPUT") {
|
|
230
|
+
const oldInput = oldEl as HTMLInputElement;
|
|
231
|
+
const newInput = newEl as HTMLInputElement;
|
|
232
|
+
if (oldInput.checked !== newInput.checked) {
|
|
233
|
+
oldInput.checked = newInput.checked;
|
|
234
|
+
}
|
|
235
|
+
if (oldInput.value !== newInput.value) {
|
|
236
|
+
oldInput.value = newInput.value;
|
|
237
|
+
}
|
|
238
|
+
} else if (oldEl.nodeName === "TEXTAREA") {
|
|
239
|
+
const oldArea = oldEl as HTMLTextAreaElement;
|
|
240
|
+
const newArea = newEl as HTMLTextAreaElement;
|
|
241
|
+
if (oldArea.value !== newArea.value) {
|
|
242
|
+
oldArea.value = newArea.value;
|
|
243
|
+
}
|
|
244
|
+
} else if (oldEl.nodeName === "OPTION") {
|
|
245
|
+
const oldOption = oldEl as HTMLOptionElement;
|
|
246
|
+
const newOption = newEl as HTMLOptionElement;
|
|
247
|
+
if (oldOption.selected !== newOption.selected) {
|
|
248
|
+
oldOption.selected = newOption.selected;
|
|
249
|
+
}
|
|
187
250
|
}
|
|
188
251
|
|
|
189
252
|
// Reconcile on* event handler properties (not reflected as attributes).
|
package/src/signal.ts
CHANGED
|
@@ -1,18 +1,38 @@
|
|
|
1
1
|
// Reactive signals: signal<T>, effect (with cleanup), batch, computed, untracked.
|
|
2
2
|
// Preact-style doubly-linked source/target edges, pull-based glitch-free refresh,
|
|
3
|
-
//
|
|
4
|
-
//
|
|
3
|
+
// per-node version fast-skip, bitfield flags.
|
|
4
|
+
// Apache-2.0.
|
|
5
5
|
//
|
|
6
6
|
// Port provenance: graph/scheduler semantics verified against
|
|
7
7
|
// @preact/signals-core@1.14.4 (drift audit 2026-07-17). Deliberate deltas
|
|
8
8
|
// from upstream, pinned by tests: Object.is equality incl. NaN (upstream:
|
|
9
9
|
// `!==`); custom `equals` / `equals: false` options (upstream has none);
|
|
10
10
|
// isolate-and-continue effect error policy via setEffectErrorHandler
|
|
11
|
-
// (upstream disposes-and-rethrows); cycle guard in endBatch
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
11
|
+
// (upstream disposes-and-rethrows); the cycle guard lives in endBatch's drain
|
|
12
|
+
// and un-notifies the chain it drops (upstream guards ONLY in the signal
|
|
13
|
+
// setter, evaluated before the write, so it never drops a chain); and the drain
|
|
14
|
+
// is re-entrant — `if (--batchDepth > 0) return` lets a write from an effect
|
|
15
|
+
// body start a fresh drain, where upstream's `if (batchDepth > 1) return` plus
|
|
16
|
+
// a post-drain `batchDepth--` keeps one drain per outermost batch.
|
|
17
|
+
//
|
|
18
|
+
// Upstream machinery NOT ported by design:
|
|
19
|
+
// - watched/unwatched hooks, createModel/action (1.13+);
|
|
20
|
+
// - the 1.14.x batch-snapshot A->B->A revert optimization — the
|
|
21
|
+
// version-monotonicity bug its first release carried
|
|
22
|
+
// (preactjs/signals#947) never existed here;
|
|
23
|
+
// - the `globalVersion` epoch fast-skip in refreshComputed. All three
|
|
24
|
+
// refresh call sites pre-check DIRTY, and because this port never
|
|
25
|
+
// unsubscribes a computed from its sources, notifyTargets always reaches
|
|
26
|
+
// every dependent computed — so DIRTY is a complete staleness signal.
|
|
27
|
+
// Upstream needs the epoch for computeds with no subscribers, which have
|
|
28
|
+
// no live edges. Do NOT try to revive it by deleting the DIRTY pre-checks:
|
|
29
|
+
// this refreshComputed has neither upstream's TRACKING shortcut nor its
|
|
30
|
+
// internal needsToRecompute gate, so an epoch miss recomputes
|
|
31
|
+
// unconditionally and six recompute-gating tests go red.
|
|
32
|
+
// - upstream's endBatch error capture and rethrow. Effect errors are
|
|
33
|
+
// isolated inside runEffect (see its contract below), so nothing can throw
|
|
34
|
+
// out of the drain and there was never anything to capture.
|
|
35
|
+
//
|
|
16
36
|
// Re-audit on upstream releases: diff src/index.ts against this file's
|
|
17
37
|
// mechanisms and harvest engine-internal fixes (see CONTRIBUTING.md).
|
|
18
38
|
|
|
@@ -59,7 +79,6 @@ interface ComputedNode extends SourceNode, TargetNode {
|
|
|
59
79
|
_isComputed: true;
|
|
60
80
|
_fn: () => unknown;
|
|
61
81
|
_equals: ((a: unknown, b: unknown) => boolean) | undefined;
|
|
62
|
-
_globalVersion: number;
|
|
63
82
|
}
|
|
64
83
|
|
|
65
84
|
interface EffectNode {
|
|
@@ -76,7 +95,6 @@ let evalContext: TargetNode | undefined;
|
|
|
76
95
|
let batchDepth = 0;
|
|
77
96
|
let batchedEffect: EffectNode | undefined;
|
|
78
97
|
let batchIteration = 0;
|
|
79
|
-
let globalVersion = 0;
|
|
80
98
|
|
|
81
99
|
// Max effect-scheduling passes within one endBatch before assuming a cyclic
|
|
82
100
|
// effect-write loop and bailing with "Cycle detected".
|
|
@@ -225,7 +243,22 @@ function notifyTargets(source: SourceNode): void {
|
|
|
225
243
|
const target = node._target;
|
|
226
244
|
if (target._isComputed) {
|
|
227
245
|
// It's a ComputedNode
|
|
228
|
-
if (
|
|
246
|
+
if (target._flags & RUNNING) {
|
|
247
|
+
// A source written while this computed is mid-evaluation. Mark it stale
|
|
248
|
+
// so the next read re-runs it — every refresh call site pre-checks
|
|
249
|
+
// DIRTY, so without this the computed serves its pre-write value
|
|
250
|
+
// forever — but do NOT recurse: its own version has not moved yet, and
|
|
251
|
+
// recursing out of a RUNNING node is how a notify ping-pong is built.
|
|
252
|
+
//
|
|
253
|
+
// This repairs the PULL side only, deliberately. Two limits the next
|
|
254
|
+
// reader should not have to rediscover: (1) the computed's own
|
|
255
|
+
// subscribers are not woken here, so an effect that depends on it will
|
|
256
|
+
// not re-run until something else notifies it; (2) a computed that
|
|
257
|
+
// writes its own source unconditionally costs one extra recompute per
|
|
258
|
+
// read, and inside an effect it degrades into the documented
|
|
259
|
+
// MAX_BATCH_ITERATIONS "Cycle detected" path rather than hanging.
|
|
260
|
+
target._flags |= DIRTY;
|
|
261
|
+
} else if (!(target._flags & NOTIFIED)) {
|
|
229
262
|
target._flags |= DIRTY | NOTIFIED;
|
|
230
263
|
notifyTargets(target as unknown as SourceNode);
|
|
231
264
|
target._flags &= ~NOTIFIED;
|
|
@@ -271,12 +304,6 @@ function refreshComputed(comp: ComputedNode): void {
|
|
|
271
304
|
throw new Error("Cycle detected");
|
|
272
305
|
}
|
|
273
306
|
|
|
274
|
-
// Fast-path: nothing changed globally since last check
|
|
275
|
-
if (comp._globalVersion === globalVersion && !(comp._flags & DIRTY)) {
|
|
276
|
-
return;
|
|
277
|
-
}
|
|
278
|
-
comp._globalVersion = globalVersion;
|
|
279
|
-
|
|
280
307
|
const prevContext = evalContext;
|
|
281
308
|
evalContext = comp;
|
|
282
309
|
const prevFlags = comp._flags;
|
|
@@ -346,14 +373,31 @@ function endBatch(): void {
|
|
|
346
373
|
if (--batchDepth > 0) {
|
|
347
374
|
return;
|
|
348
375
|
}
|
|
349
|
-
|
|
350
|
-
|
|
376
|
+
// The drain is re-entrant: a write, a batch() or a flushSync() from an effect
|
|
377
|
+
// body sees batchDepth back at 0 and starts a nested drain, and the cycle guard
|
|
378
|
+
// below counts on those nested passes — a two-effect ping-pong recurses one
|
|
379
|
+
// level per pass rather than looping here. So a nested drain must RESTORE the
|
|
380
|
+
// count it inherited instead of zeroing it: zeroing let any nested drain,
|
|
381
|
+
// including a flushSync() that found nothing to flush, wipe the passes the
|
|
382
|
+
// enclosing drain had accumulated and multiply the effective cap.
|
|
383
|
+
const enclosingIteration = batchIteration;
|
|
351
384
|
while (batchedEffect !== undefined) {
|
|
352
385
|
let eff: EffectNode | undefined = batchedEffect;
|
|
353
386
|
batchedEffect = undefined;
|
|
354
387
|
batchIteration++;
|
|
355
388
|
if (batchIteration > MAX_BATCH_ITERATIONS) {
|
|
356
|
-
batchIteration =
|
|
389
|
+
batchIteration = enclosingIteration;
|
|
390
|
+
// Un-notify the chain we are dropping: notifyTargets skips a NOTIFIED
|
|
391
|
+
// effect, so an effect left NOTIFIED here could never be queued again —
|
|
392
|
+
// bailing would silently deafen every effect in flight, including
|
|
393
|
+
// bystanders that merely share a signal with the cycling one.
|
|
394
|
+
let drop: EffectNode | undefined = eff;
|
|
395
|
+
while (drop !== undefined) {
|
|
396
|
+
const nextDrop: EffectNode | undefined = drop._nextBatchedEffect;
|
|
397
|
+
drop._nextBatchedEffect = undefined;
|
|
398
|
+
drop._flags &= ~NOTIFIED;
|
|
399
|
+
drop = nextDrop;
|
|
400
|
+
}
|
|
357
401
|
throw new Error("Cycle detected");
|
|
358
402
|
}
|
|
359
403
|
while (eff !== undefined) {
|
|
@@ -361,22 +405,12 @@ function endBatch(): void {
|
|
|
361
405
|
eff._nextBatchedEffect = undefined;
|
|
362
406
|
eff._flags &= ~NOTIFIED;
|
|
363
407
|
if (!(eff._flags & DISPOSED) && needsToRecompute(eff)) {
|
|
364
|
-
|
|
365
|
-
runEffect(eff);
|
|
366
|
-
} catch (e) {
|
|
367
|
-
if (!hasError) {
|
|
368
|
-
error = e;
|
|
369
|
-
hasError = true;
|
|
370
|
-
}
|
|
371
|
-
}
|
|
408
|
+
runEffect(eff);
|
|
372
409
|
}
|
|
373
410
|
eff = next;
|
|
374
411
|
}
|
|
375
412
|
}
|
|
376
|
-
batchIteration =
|
|
377
|
-
if (hasError) {
|
|
378
|
-
throw error;
|
|
379
|
-
}
|
|
413
|
+
batchIteration = enclosingIteration;
|
|
380
414
|
}
|
|
381
415
|
|
|
382
416
|
function runCleanup(eff: EffectNode): void {
|
|
@@ -395,6 +429,13 @@ function runCleanup(eff: EffectNode): void {
|
|
|
395
429
|
}
|
|
396
430
|
}
|
|
397
431
|
|
|
432
|
+
// Contract: runEffect NEVER throws, and both callers depend on it.
|
|
433
|
+
// Its body and its cleanup each route to safeCallHandler, which swallows even a
|
|
434
|
+
// throwing handler; everything else here is a pointer walk over a well-formed
|
|
435
|
+
// list. endBatch's drain and the effect() factory therefore call it bare. A
|
|
436
|
+
// throw escaping this function would abandon the rest of the queued chain
|
|
437
|
+
// mid-drain — the same shape of failure as the cycle bail's NOTIFIED leak. If
|
|
438
|
+
// you add a statement here that can throw, wrap it HERE, not at the call sites.
|
|
398
439
|
function runEffect(eff: EffectNode): void {
|
|
399
440
|
// Run cleanup first (untracked)
|
|
400
441
|
runCleanup(eff);
|
|
@@ -487,7 +528,6 @@ export function signal<T>(initial: T, options?: SignalOptions<T>): Signal<T> {
|
|
|
487
528
|
}
|
|
488
529
|
node._value = v;
|
|
489
530
|
node._version++;
|
|
490
|
-
globalVersion++;
|
|
491
531
|
startBatch();
|
|
492
532
|
try {
|
|
493
533
|
notifyTargets(node);
|
|
@@ -519,7 +559,6 @@ export function computed<T>(fn: () => T, options?: SignalOptions<T>): ReadonlySi
|
|
|
519
559
|
_fn: fn,
|
|
520
560
|
_equals:
|
|
521
561
|
eq === false ? () => false : eq ? (a: unknown, b: unknown) => eq(a as T, b as T) : undefined,
|
|
522
|
-
_globalVersion: -1,
|
|
523
562
|
};
|
|
524
563
|
|
|
525
564
|
const c = {
|
|
@@ -577,11 +616,7 @@ export function effect(fn: () => Cleanup): () => void {
|
|
|
577
616
|
};
|
|
578
617
|
|
|
579
618
|
// Run immediately
|
|
580
|
-
|
|
581
|
-
runEffect(eff);
|
|
582
|
-
} catch (e) {
|
|
583
|
-
safeCallHandler(e);
|
|
584
|
-
}
|
|
619
|
+
runEffect(eff);
|
|
585
620
|
|
|
586
621
|
return () => {
|
|
587
622
|
if (eff._flags & DISPOSED) {
|