@cplieger/reactive 1.2.4 → 1.3.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/LICENSE +201 -692
- package/README.md +65 -45
- package/jsr.json +3 -3
- package/package.json +34 -13
- package/src/bind-list.ts +16 -3
- package/src/collection.ts +11 -3
- package/src/reconcile-tree.ts +88 -11
- package/src/signal.ts +79 -33
- package/src/store.ts +14 -2
package/README.md
CHANGED
|
@@ -3,12 +3,25 @@
|
|
|
3
3
|
[](https://www.npmjs.com/package/@cplieger/reactive)
|
|
4
4
|
[](https://jsr.io/@cplieger/reactive)
|
|
5
5
|
[](https://github.com/cplieger/reactive/actions/workflows/coverage.yml)
|
|
6
|
+
[](https://github.com/cplieger/reactive/issues?q=label%3Astryker-tracker)
|
|
6
7
|
[](https://www.bestpractices.dev/projects/13218)
|
|
7
8
|
[](https://scorecard.dev/viewer/?uri=github.com/cplieger/reactive)
|
|
8
9
|
|
|
9
10
|
> Signals + collections + DOM-reconciliation micro-framework for TypeScript
|
|
10
11
|
|
|
11
|
-
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.
|
|
12
25
|
|
|
13
26
|
Mirrors semantics from @preact/signals-core and solid-js reactivity.
|
|
14
27
|
|
|
@@ -68,86 +81,93 @@ isComputed(doubled); // true
|
|
|
68
81
|
|
|
69
82
|
### Signals
|
|
70
83
|
|
|
71
|
-
- `signal<T>(initial, options?): Signal<T
|
|
72
|
-
- `computed<T>(fn, options?): ReadonlySignal<T
|
|
73
|
-
- `effect(fn: () => Cleanup): () => void
|
|
74
|
-
- `batch(fn)
|
|
75
|
-
- `flushSync()
|
|
76
|
-
- `untracked<T>(fn): T
|
|
77
|
-
- `on(deps, fn, options?): () => U
|
|
78
|
-
- `subscribe(signal, cb): () => void
|
|
79
|
-
- `isSignal(value): boolean
|
|
80
|
-
- `isComputed(value): boolean
|
|
81
|
-
- `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
|
|
82
95
|
|
|
83
96
|
### DOM building & reconciliation
|
|
84
97
|
|
|
85
|
-
- `el(tag, attrs?, ...children)
|
|
86
|
-
- `reconcile<T>(parent, items, spec)
|
|
87
|
-
- `patch(parent, ...children)
|
|
88
|
-
- `reconcileChildren(parent, newChildren)
|
|
89
|
-
- `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
|
|
90
103
|
|
|
91
104
|
### Store
|
|
92
105
|
|
|
93
|
-
`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.
|
|
94
107
|
|
|
95
|
-
- `createStore<M>(): Store<M
|
|
96
|
-
- `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.
|
|
97
110
|
|
|
98
111
|
### Collections
|
|
99
112
|
|
|
100
|
-
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`.
|
|
101
114
|
|
|
102
|
-
- `createCollection<T>(keyOf: (item: T) => string): Collection<T
|
|
103
|
-
- `setAll(items)
|
|
104
|
-
- `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)
|
|
105
118
|
- `update(id, next | updater)` / `remove(id)` / `clear()`
|
|
106
119
|
- `get(id)` / `has(id)` (untracked), `signalFor(id)` (reactive per-entity), `size`
|
|
107
|
-
- `ids: ReadonlySignal<readonly string[]
|
|
108
|
-
- `items()
|
|
120
|
+
- `ids: ReadonlySignal<readonly string[]>`: structure tier (add/remove/reorder only)
|
|
121
|
+
- `items()`: ordered reactive snapshot (tracks order + every entity)
|
|
109
122
|
|
|
110
123
|
### List rendering
|
|
111
124
|
|
|
112
|
-
- `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.
|
|
113
126
|
|
|
114
127
|
### Event bus
|
|
115
128
|
|
|
116
|
-
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).
|
|
117
130
|
|
|
118
|
-
- `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`).
|
|
119
132
|
|
|
120
133
|
## Correctness guarantees
|
|
121
134
|
|
|
122
|
-
- **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.
|
|
123
136
|
- **Cycle detection**: Reading a computed that is currently being computed throws `Error("Cycle detected")`.
|
|
124
137
|
- **Error caching**: If a computed's fn throws, the error is cached and rethrown on subsequent reads until dependencies change.
|
|
125
138
|
- **Computed is read-only**: Setting `.value` on a computed throws `Error("Cannot set a computed signal")`.
|
|
126
139
|
- **Synchronous batch**: `batch()` flushes effects synchronously at the end of the outermost batch (matching @preact/signals-core and solid-js).
|
|
127
140
|
|
|
128
|
-
##
|
|
141
|
+
## Unsupported by Design
|
|
129
142
|
|
|
130
143
|
The following features are intentionally NOT implemented:
|
|
131
144
|
|
|
132
|
-
| Feature
|
|
133
|
-
|
|
|
134
|
-
| **Effect ownership tree / `createRoot`**
|
|
135
|
-
| **Nested effect auto-disposal**
|
|
136
|
-
| **Lazy activation / `onMount` lifecycle**
|
|
137
|
-
| **`Signal.subtle.Watcher` / notify-on-dirty**
|
|
138
|
-
| **Introspection APIs**
|
|
139
|
-
| **Explicit computed disposal**
|
|
140
|
-
| **SSR / server-side isolation**
|
|
141
|
-
| **Async signals / resources**
|
|
142
|
-
| **Transactions**
|
|
143
|
-
| **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.
|
|
144
164
|
|
|
145
165
|
## Disclaimer
|
|
146
166
|
|
|
147
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.
|
|
148
168
|
|
|
149
|
-
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.
|
|
150
170
|
|
|
151
171
|
## License
|
|
152
172
|
|
|
153
|
-
|
|
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.0",
|
|
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.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"exports": {
|
|
@@ -13,18 +13,24 @@
|
|
|
13
13
|
},
|
|
14
14
|
"devDependencies": {
|
|
15
15
|
"@eslint/js": "10.0.1",
|
|
16
|
+
"@stryker-mutator/core": "10.0.0",
|
|
17
|
+
"@stryker-mutator/typescript-checker": "10.0.0",
|
|
18
|
+
"@stryker-mutator/vitest-runner": "10.0.0",
|
|
16
19
|
"@typescript/native": "npm:typescript@7.0.2",
|
|
17
|
-
"@vitest/coverage-v8": "4.1.
|
|
18
|
-
"eslint": "10.
|
|
19
|
-
"fast-check": "4.
|
|
20
|
-
"happy-dom": "20.
|
|
21
|
-
"prettier": "3.9.
|
|
20
|
+
"@vitest/coverage-v8": "4.1.11",
|
|
21
|
+
"eslint": "10.8.1",
|
|
22
|
+
"fast-check": "4.9.0",
|
|
23
|
+
"happy-dom": "20.11.2",
|
|
24
|
+
"prettier": "3.9.6",
|
|
22
25
|
"typescript": "npm:@typescript/typescript6@6.0.2",
|
|
23
|
-
"typescript-eslint": "8.
|
|
24
|
-
"vitest": "4.1.
|
|
26
|
+
"typescript-eslint": "8.67.0",
|
|
27
|
+
"vitest": "4.1.11"
|
|
28
|
+
},
|
|
29
|
+
"overrides": {
|
|
30
|
+
"qs": "^6.15.2"
|
|
25
31
|
},
|
|
26
32
|
"description": "Signals + DOM-reconciliation reactive core for TypeScript (zero dependencies)",
|
|
27
|
-
"license": "
|
|
33
|
+
"license": "Apache-2.0",
|
|
28
34
|
"types": "./src/index.ts",
|
|
29
35
|
"publishConfig": {
|
|
30
36
|
"access": "public"
|
|
@@ -33,11 +39,26 @@
|
|
|
33
39
|
"type": "git",
|
|
34
40
|
"url": "git+https://github.com/cplieger/reactive.git"
|
|
35
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
|
+
],
|
|
36
59
|
"files": [
|
|
37
60
|
"src/",
|
|
38
|
-
"
|
|
39
|
-
"!src
|
|
40
|
-
"!src/**/fc-strict-setup.ts",
|
|
41
|
-
"jsr.json"
|
|
61
|
+
"jsr.json",
|
|
62
|
+
"!src/**/*.test.ts"
|
|
42
63
|
]
|
|
43
64
|
}
|
package/src/bind-list.ts
CHANGED
|
@@ -19,7 +19,9 @@ import { effect, type ReadonlySignal } from "./signal.js";
|
|
|
19
19
|
|
|
20
20
|
/** The minimal reactive surface `bindList` renders: a structure signal (the
|
|
21
21
|
* ordered ids) and a per-id content-signal lookup. `Collection<T>` satisfies
|
|
22
|
-
* this; so does `{ ids: computed(...), signalFor: collection.signalFor }`.
|
|
22
|
+
* this; so does `{ ids: computed(...), signalFor: collection.signalFor }`.
|
|
23
|
+
* An id with no matching signal (an inconsistent source) is skipped — no row
|
|
24
|
+
* is rendered for it until the source becomes consistent. */
|
|
23
25
|
export interface ListSource<T> {
|
|
24
26
|
readonly ids: ReadonlySignal<readonly string[]>;
|
|
25
27
|
signalFor(id: string): ReadonlySignal<T> | undefined;
|
|
@@ -51,12 +53,23 @@ export function bindList<T>(
|
|
|
51
53
|
const rowDisposers = new Map<string, () => void>();
|
|
52
54
|
|
|
53
55
|
const disposeStructural = effect(() => {
|
|
54
|
-
|
|
56
|
+
// Structure tier: re-runs on add/remove/reorder only. An id whose entity
|
|
57
|
+
// signal is missing (an inconsistent ListSource) is skipped instead of
|
|
58
|
+
// mounting a row with a manufactured `undefined as T` value; it renders
|
|
59
|
+
// once the source becomes consistent (the next `ids` change).
|
|
60
|
+
const ids = source.ids.value.filter((id) => source.signalFor(id) !== undefined);
|
|
55
61
|
reconcile<string>(parent, ids, {
|
|
56
62
|
key: (id) => id,
|
|
57
63
|
mount: (id) => {
|
|
58
64
|
const sig = source.signalFor(id);
|
|
59
|
-
|
|
65
|
+
if (sig === undefined) {
|
|
66
|
+
// Unreachable via the structural filter above (same synchronous
|
|
67
|
+
// pass); reachable only by a source whose signalFor is not stable
|
|
68
|
+
// within one read — surface that loudly instead of manufacturing
|
|
69
|
+
// an entity value. Routed to the effect error handler.
|
|
70
|
+
throw new Error(`bindList: no entity signal for id "${id}"`);
|
|
71
|
+
}
|
|
72
|
+
const el = spec.mount(sig.peek(), id);
|
|
60
73
|
if (spec.update !== undefined) {
|
|
61
74
|
const update = spec.update;
|
|
62
75
|
// content tier: isolated per-row effect; runs now (fills content)
|
package/src/collection.ts
CHANGED
|
@@ -12,7 +12,10 @@ import { SignalMap } from "./signal-map.js";
|
|
|
12
12
|
/** A reactive, ordered, keyed collection of entities of type T. */
|
|
13
13
|
export interface Collection<T> {
|
|
14
14
|
/** Replace the whole collection (e.g. initial load). Entities no longer
|
|
15
|
-
* present are dropped; the order becomes
|
|
15
|
+
* present are dropped; the order becomes `items`, deduplicated by key
|
|
16
|
+
* (first occurrence keeps the position, the last value wins — Map
|
|
17
|
+
* semantics), so the order tier can never list an id twice while the
|
|
18
|
+
* entity tier holds a single signal for it. */
|
|
16
19
|
setAll(items: readonly T[]): void;
|
|
17
20
|
/** Add a new entity or replace an existing one (keyed by `keyOf`). New
|
|
18
21
|
* entities are appended to the order. */
|
|
@@ -65,8 +68,13 @@ export function createCollection<T>(keyOf: (item: T) => string): Collection<T> {
|
|
|
65
68
|
const seen = new Set<string>();
|
|
66
69
|
for (const item of items) {
|
|
67
70
|
const id = keyOf(item);
|
|
68
|
-
|
|
69
|
-
|
|
71
|
+
// Deduplicate repeated keys (first occurrence keeps the position, the
|
|
72
|
+
// last value wins) so the structure and entity tiers cannot disagree:
|
|
73
|
+
// one signal per id, one order entry per id.
|
|
74
|
+
if (!seen.has(id)) {
|
|
75
|
+
seen.add(id);
|
|
76
|
+
ids.push(id);
|
|
77
|
+
}
|
|
70
78
|
sigs.ensure(id, item).value = item;
|
|
71
79
|
}
|
|
72
80
|
for (const id of order.peek()) {
|
package/src/reconcile-tree.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Structural tree-diff: reconcile a parent's children against new nodes.
|
|
2
2
|
// Handles attribute patching, text node updates, element reordering,
|
|
3
|
-
// and recursive child reconciliation. Keying
|
|
3
|
+
// and recursive child reconciliation. Keying: a specific `*-id` attribute
|
|
4
|
+
// wins over the generic `data-col`; duplicate-key siblings match in
|
|
5
|
+
// document order.
|
|
4
6
|
|
|
5
7
|
const handlerKeysMap = new WeakMap<HTMLElement, Set<string>>();
|
|
6
8
|
|
|
@@ -38,11 +40,19 @@ export function patch(
|
|
|
38
40
|
/** Reconcile a parent node's children against a new set of child nodes, patching in place. */
|
|
39
41
|
export function reconcileChildren(parent: Node, newChildren: Node[]): void {
|
|
40
42
|
const oldChildren = Array.from(parent.childNodes);
|
|
41
|
-
|
|
43
|
+
// Per-key FIFO queues: siblings sharing a key (e.g. two `data-col="meta"`
|
|
44
|
+
// cells in one row) match in document order (first↔first, second↔second)
|
|
45
|
+
// instead of a last-wins map entry mispairing them and churning nodes.
|
|
46
|
+
const oldByKey = new Map<string, Node[]>();
|
|
42
47
|
for (const child of oldChildren) {
|
|
43
48
|
const key = nodeKey(child);
|
|
44
49
|
if (key) {
|
|
45
|
-
oldByKey.
|
|
50
|
+
const queue = oldByKey.get(key);
|
|
51
|
+
if (queue === undefined) {
|
|
52
|
+
oldByKey.set(key, [child]);
|
|
53
|
+
} else {
|
|
54
|
+
queue.push(child);
|
|
55
|
+
}
|
|
46
56
|
}
|
|
47
57
|
}
|
|
48
58
|
|
|
@@ -54,10 +64,16 @@ export function reconcileChildren(parent: Node, newChildren: Node[]): void {
|
|
|
54
64
|
}
|
|
55
65
|
const newKey = nodeKey(newChild);
|
|
56
66
|
|
|
57
|
-
let matched
|
|
58
|
-
if (
|
|
59
|
-
oldByKey.
|
|
60
|
-
|
|
67
|
+
let matched: Node | null = null;
|
|
68
|
+
if (newKey) {
|
|
69
|
+
const queue = oldByKey.get(newKey);
|
|
70
|
+
if (queue !== undefined) {
|
|
71
|
+
matched = queue.shift() ?? null;
|
|
72
|
+
if (queue.length === 0) {
|
|
73
|
+
oldByKey.delete(newKey);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
} else {
|
|
61
77
|
// Unkeyed new node: match the next UNKEYED old node by position, advancing
|
|
62
78
|
// past keyed old nodes (which are reserved for key-based matching) and any
|
|
63
79
|
// already-consumed slots. This mirrors the documented contract ("unkeyed
|
|
@@ -139,12 +155,26 @@ function nodeKey(node: Node): string {
|
|
|
139
155
|
if (node.nodeType !== 1) {
|
|
140
156
|
return "";
|
|
141
157
|
}
|
|
158
|
+
// A specific entity id (`*-id`, e.g. data-cov-id / data-act-id) takes
|
|
159
|
+
// precedence over the generic column key (`data-col`), so an element
|
|
160
|
+
// carrying both keys by its entity identity — a generic first-in-attribute-
|
|
161
|
+
// order `data-col` must not shadow it.
|
|
162
|
+
//
|
|
163
|
+
// The `data-col` scan is last-wins. The DOM keys attributes by (namespace,
|
|
164
|
+
// localName), so `setAttributeNS` with a foreign namespace and no prefix can
|
|
165
|
+
// put a second attribute whose `.name` is also "data-col" on the element;
|
|
166
|
+
// first-wins would key the node off the foreign one and refuse to match the
|
|
167
|
+
// author's own, destroying and recreating the node instead of reusing it.
|
|
168
|
+
let colKey = "";
|
|
142
169
|
for (const attr of (node as Element).attributes) {
|
|
143
|
-
if (attr.name
|
|
170
|
+
if (attr.name.endsWith("-id")) {
|
|
144
171
|
return `${attr.name}=${attr.value}`;
|
|
145
172
|
}
|
|
173
|
+
if (attr.name === "data-col") {
|
|
174
|
+
colKey = `${attr.name}=${attr.value}`;
|
|
175
|
+
}
|
|
146
176
|
}
|
|
147
|
-
return
|
|
177
|
+
return colKey;
|
|
148
178
|
}
|
|
149
179
|
|
|
150
180
|
function patchAttrs(oldEl: HTMLElement, newEl: HTMLElement): void {
|
|
@@ -158,8 +188,55 @@ function patchAttrs(oldEl: HTMLElement, newEl: HTMLElement): void {
|
|
|
158
188
|
oldEl.removeAttribute(attr.name);
|
|
159
189
|
}
|
|
160
190
|
}
|
|
161
|
-
|
|
162
|
-
|
|
191
|
+
|
|
192
|
+
// Unreflected live state. `checked` (input), `selected` (option) and the
|
|
193
|
+
// `value` of an input/textarea are dirty-flag IDL properties: their content
|
|
194
|
+
// attributes are the DEFAULTS (`defaultChecked` / `defaultSelected` /
|
|
195
|
+
// `defaultValue`), so the two loops above equalise the default and never the
|
|
196
|
+
// live value. Without this, a keyed re-patch could not turn a checkbox off,
|
|
197
|
+
// move a selection, or replace the text in a field, ever.
|
|
198
|
+
//
|
|
199
|
+
// Syncing `value` overwrites whatever a user has typed, and it moves the caret
|
|
200
|
+
// to the end. That is correct only where a re-render cannot land mid-keystroke,
|
|
201
|
+
// which is a property of the CONSUMER, not of this function: a form re-rendered
|
|
202
|
+
// by a click (open, save, reset) is safe, one re-rendered by a timer or a server
|
|
203
|
+
// push is not. The README records the obligation; a consumer in the second shape
|
|
204
|
+
// must drive the field from an effect instead of re-rendering over it.
|
|
205
|
+
//
|
|
206
|
+
// Not covered here: `value` on an <option>/<progress>/<li> DOES reflect, so the
|
|
207
|
+
// loops above already carry it. A <select>'s value is derived from its options,
|
|
208
|
+
// which the OPTION branch below reconciles. `indeterminate` is unreflected too,
|
|
209
|
+
// but el() cannot set it, so it is outside patch()'s reach.
|
|
210
|
+
//
|
|
211
|
+
// canPatch has already established that both nodes share a nodeName, so one
|
|
212
|
+
// side decides the branch.
|
|
213
|
+
//
|
|
214
|
+
// Each `!==` guard is deliberate and no test here can pin it: assigning the
|
|
215
|
+
// same string to `.value` is a no-op through the DOM, but in a real browser it
|
|
216
|
+
// sets the dirty flag and moves the caret to the end, so an unconditional write
|
|
217
|
+
// would jump the caret on every re-render even when nothing changed. happy-dom
|
|
218
|
+
// exposes no caret, hence a permanently surviving `-> if (true)` mutant on each.
|
|
219
|
+
if (oldEl.nodeName === "INPUT") {
|
|
220
|
+
const oldInput = oldEl as HTMLInputElement;
|
|
221
|
+
const newInput = newEl as HTMLInputElement;
|
|
222
|
+
if (oldInput.checked !== newInput.checked) {
|
|
223
|
+
oldInput.checked = newInput.checked;
|
|
224
|
+
}
|
|
225
|
+
if (oldInput.value !== newInput.value) {
|
|
226
|
+
oldInput.value = newInput.value;
|
|
227
|
+
}
|
|
228
|
+
} else if (oldEl.nodeName === "TEXTAREA") {
|
|
229
|
+
const oldArea = oldEl as HTMLTextAreaElement;
|
|
230
|
+
const newArea = newEl as HTMLTextAreaElement;
|
|
231
|
+
if (oldArea.value !== newArea.value) {
|
|
232
|
+
oldArea.value = newArea.value;
|
|
233
|
+
}
|
|
234
|
+
} else if (oldEl.nodeName === "OPTION") {
|
|
235
|
+
const oldOption = oldEl as HTMLOptionElement;
|
|
236
|
+
const newOption = newEl as HTMLOptionElement;
|
|
237
|
+
if (oldOption.selected !== newOption.selected) {
|
|
238
|
+
oldOption.selected = newOption.selected;
|
|
239
|
+
}
|
|
163
240
|
}
|
|
164
241
|
|
|
165
242
|
// Reconcile on* event handler properties (not reflected as attributes).
|