@cplieger/reactive 1.0.6 → 1.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 +31 -5
- package/jsr.json +1 -1
- package/package.json +2 -2
- package/src/bind-list.ts +92 -0
- package/src/bus.ts +133 -0
- package/src/collection.ts +173 -0
- package/src/el.ts +73 -0
- package/src/index.ts +9 -0
- package/src/reconcile-tree.ts +1 -1
- package/src/signal-map.ts +39 -0
- package/src/store.ts +34 -120
package/README.md
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
[](https://jsr.io/@cplieger/reactive)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
|
|
8
|
-
> Signals + DOM-reconciliation micro-framework for TypeScript
|
|
8
|
+
> Signals + collections + DOM-reconciliation micro-framework for TypeScript
|
|
9
9
|
|
|
10
|
-
A standalone reactive
|
|
10
|
+
A standalone reactive app-skeleton: fine-grained signals with automatic dependency tracking and synchronous batched effects, a typed per-key reactive store, a dynamic per-id signal registry (`SignalMap`), a reactive ordered keyed `Collection`, a two-tier list render binding (`bindList`), a CSP-safe element factory (`el`), keyed-list DOM reconciliation, structural tree-diffing, and a typed event bus (`createBus`). The store, `SignalMap`, and `Collection` are thin facades over the single signal engine — one reactive core, not several. The bus is the deliberate counterpart for discrete events (state lives in signals; events go on the bus). Zero dependencies beyond the DOM API.
|
|
11
11
|
|
|
12
12
|
Mirrors semantics from @preact/signals-core and solid-js reactivity.
|
|
13
13
|
|
|
@@ -79,16 +79,42 @@ isComputed(doubled); // true
|
|
|
79
79
|
- `isComputed(value): boolean` — type guard for computed signals
|
|
80
80
|
- `setEffectErrorHandler(handler): EffectErrorHandler` — set global error handler for effects; returns the previous handler
|
|
81
81
|
|
|
82
|
-
### DOM
|
|
82
|
+
### DOM building & reconciliation
|
|
83
83
|
|
|
84
|
+
- `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.
|
|
84
85
|
- `reconcile<T>(parent, items, spec)` — keyed-list DOM reconciliation with mount/update/onRemove lifecycle
|
|
85
|
-
- `patch(parent, ...children)` — structural tree-diff, replacing a parent's children with reconciled new nodes
|
|
86
|
+
- `patch(parent, ...children)` — structural tree-diff, replacing a parent's children with reconciled new nodes. Element nodes are keyed by their first `data-col` or `*-id` attribute (so reorders/re-patches reuse the matched node); unkeyed nodes match by position.
|
|
86
87
|
- `reconcileChildren(parent, newChildren)` — low-level child reconciliation against existing DOM
|
|
87
88
|
- `trackHandler(el, key)` — register an `on*` property for handler reconciliation during tree-diff
|
|
88
89
|
|
|
89
90
|
### Store
|
|
90
91
|
|
|
91
|
-
|
|
92
|
+
`createStore` and `SignalMap` are thin facades over the one signal engine — there is no second reactivity implementation. `createStore` lazily backs each key with a signal; `SignalMap` is a registry of signals keyed by a runtime string id.
|
|
93
|
+
|
|
94
|
+
- `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). A `computed` key whose fn reads its own output throws `Error("Cycle detected")` rather than looping.
|
|
95
|
+
- `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.
|
|
96
|
+
|
|
97
|
+
### Collections
|
|
98
|
+
|
|
99
|
+
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`.
|
|
100
|
+
|
|
101
|
+
- `createCollection<T>(keyOf: (item: T) => string): Collection<T>` — returns a collection with:
|
|
102
|
+
- `setAll(items)` — replace everything (same-order replacement does not bump `ids`)
|
|
103
|
+
- `upsert(item)` — add/replace one (appends new ids); `prepend(items)` — add to the front (scroll-up/load-older pagination)
|
|
104
|
+
- `update(id, next | updater)` / `remove(id)` / `clear()`
|
|
105
|
+
- `get(id)` / `has(id)` (untracked), `signalFor(id)` (reactive per-entity), `size`
|
|
106
|
+
- `ids: ReadonlySignal<readonly string[]>` — structure tier (add/remove/reorder only)
|
|
107
|
+
- `items()` — ordered reactive snapshot (tracks order + every entity)
|
|
108
|
+
|
|
109
|
+
### List rendering
|
|
110
|
+
|
|
111
|
+
- `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 = a sliced view, not a separate primitive). One structural effect tracks `source.ids` and `reconcile`s the row list; each row owns a private effect tracking only its own entity signal, so a per-entity change repaints just that row with no structural reconcile. `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.
|
|
112
|
+
|
|
113
|
+
### Event bus
|
|
114
|
+
|
|
115
|
+
State lives in signals; discrete events go on a bus. `createBus` is the typed event primitive (not reactive state — no retained value).
|
|
116
|
+
|
|
117
|
+
- `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`).
|
|
92
118
|
|
|
93
119
|
## Correctness guarantees
|
|
94
120
|
|
package/jsr.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cplieger/reactive",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"exports": {
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
"@vitest/coverage-v8": "4.1.8",
|
|
17
17
|
"eslint": "10.4.1",
|
|
18
18
|
"fast-check": "4.8.0",
|
|
19
|
-
"happy-dom": "20.10.
|
|
19
|
+
"happy-dom": "20.10.2",
|
|
20
20
|
"prettier": "3.8.3",
|
|
21
21
|
"typescript": "6.0.3",
|
|
22
22
|
"typescript-eslint": "8.60.1",
|
package/src/bind-list.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// bind-list.ts — two-tier render binding for a reactive list source.
|
|
2
|
+
//
|
|
3
|
+
// Tier 1 (structure): one effect tracks `source.ids` and runs `reconcile`
|
|
4
|
+
// over the id list, so add/remove/reorder touch the DOM minimally.
|
|
5
|
+
// Tier 2 (content): each row owns a private effect that tracks ONLY its own
|
|
6
|
+
// entity signal, so a per-entity update repaints just that row without a
|
|
7
|
+
// structural reconcile. The engine isolates nested-effect tracking
|
|
8
|
+
// (evalContext save/restore in signal.ts), so the per-row effect's reads never
|
|
9
|
+
// leak into the structural effect.
|
|
10
|
+
//
|
|
11
|
+
// The source is a `ListSource<T>` — anything exposing a structure signal
|
|
12
|
+
// (`ids`) plus a per-id content signal lookup (`signalFor`). A `Collection<T>`
|
|
13
|
+
// satisfies it directly; a filtered/sorted/paginated view is just a `computed`
|
|
14
|
+
// id-list paired with the collection's `signalFor` (so pagination is a sliced
|
|
15
|
+
// view, not a separate primitive).
|
|
16
|
+
|
|
17
|
+
import { reconcile } from "./reconcile.js";
|
|
18
|
+
import { effect, type ReadonlySignal } from "./signal.js";
|
|
19
|
+
|
|
20
|
+
/** The minimal reactive surface `bindList` renders: a structure signal (the
|
|
21
|
+
* ordered ids) and a per-id content-signal lookup. `Collection<T>` satisfies
|
|
22
|
+
* this; so does `{ ids: computed(...), signalFor: collection.signalFor }`. */
|
|
23
|
+
export interface ListSource<T> {
|
|
24
|
+
readonly ids: ReadonlySignal<readonly string[]>;
|
|
25
|
+
signalFor(id: string): ReadonlySignal<T> | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Row lifecycle for `bindList`. */
|
|
29
|
+
export interface ListSpec<T> {
|
|
30
|
+
/** Create the row element for a new entity. Receives the entity's current
|
|
31
|
+
* value and id. Keep this to structural shell creation; let `update` fill
|
|
32
|
+
* reactive content (it runs once at mount and on every later change). */
|
|
33
|
+
mount: (item: T, id: string) => HTMLElement;
|
|
34
|
+
/** Update a row when its entity changes. Runs immediately at mount and again
|
|
35
|
+
* on every per-entity change (the content tier). Omit for immutable rows
|
|
36
|
+
* whose `mount` renders everything. */
|
|
37
|
+
update?: (el: HTMLElement, item: T, id: string) => void;
|
|
38
|
+
/** Optional cleanup when a row leaves the list (fires before the element is
|
|
39
|
+
* removed from the DOM). */
|
|
40
|
+
onRemove?: (el: HTMLElement, id: string) => void;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Bind a list source to a parent node with two-tier reactivity. Returns a
|
|
44
|
+
* dispose function that tears down the structural effect and every row
|
|
45
|
+
* effect. */
|
|
46
|
+
export function bindList<T>(
|
|
47
|
+
parent: ParentNode,
|
|
48
|
+
source: ListSource<T>,
|
|
49
|
+
spec: ListSpec<T>,
|
|
50
|
+
): () => void {
|
|
51
|
+
const rowDisposers = new Map<string, () => void>();
|
|
52
|
+
|
|
53
|
+
const disposeStructural = effect(() => {
|
|
54
|
+
const ids = source.ids.value; // structure tier: re-runs on add/remove/reorder only
|
|
55
|
+
reconcile<string>(parent, ids, {
|
|
56
|
+
key: (id) => id,
|
|
57
|
+
mount: (id) => {
|
|
58
|
+
const sig = source.signalFor(id);
|
|
59
|
+
const el = spec.mount((sig === undefined ? undefined : sig.peek()) as T, id);
|
|
60
|
+
if (spec.update !== undefined) {
|
|
61
|
+
const update = spec.update;
|
|
62
|
+
// content tier: isolated per-row effect; runs now (fills content)
|
|
63
|
+
// and on every later change to THIS entity.
|
|
64
|
+
rowDisposers.set(
|
|
65
|
+
id,
|
|
66
|
+
effect(() => {
|
|
67
|
+
const cur = source.signalFor(id);
|
|
68
|
+
if (cur !== undefined) {
|
|
69
|
+
update(el, cur.value, id);
|
|
70
|
+
}
|
|
71
|
+
}),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
return el;
|
|
75
|
+
},
|
|
76
|
+
// No structural `update`: content is owned entirely by the per-row effect.
|
|
77
|
+
onRemove: (el, id) => {
|
|
78
|
+
rowDisposers.get(id)?.();
|
|
79
|
+
rowDisposers.delete(id);
|
|
80
|
+
spec.onRemove?.(el, id);
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return () => {
|
|
86
|
+
disposeStructural();
|
|
87
|
+
for (const dispose of rowDisposers.values()) {
|
|
88
|
+
dispose();
|
|
89
|
+
}
|
|
90
|
+
rowDisposers.clear();
|
|
91
|
+
};
|
|
92
|
+
}
|
package/src/bus.ts
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
// bus.ts — typed event bus (discrete events, NOT reactive state).
|
|
2
|
+
//
|
|
3
|
+
// The deliberate counterpart to signals. A signal models continuous STATE:
|
|
4
|
+
// it has a current value you read and subscribe to. A bus models discrete
|
|
5
|
+
// EVENTS: fire-and-forget notifications with no retained value. Reach for a
|
|
6
|
+
// signal/Collection when "what is the current value?" makes sense; reach for
|
|
7
|
+
// the bus when "X just happened" does (navigation, "refresh requested",
|
|
8
|
+
// "escape pressed"). Mixing the two is the smell this split prevents.
|
|
9
|
+
//
|
|
10
|
+
// Handler sets are snapshot-cached: the iteration array is rebuilt only when
|
|
11
|
+
// the set is mutated, not on every emit, and a handler unsubscribed during an
|
|
12
|
+
// emit still fires for that emit (it was in the snapshot). No-payload events
|
|
13
|
+
// use `undefined` as their payload type.
|
|
14
|
+
|
|
15
|
+
/** Handler for a bus event. Events whose payload type is `undefined` take no
|
|
16
|
+
* argument. */
|
|
17
|
+
export type BusHandler<T> = T extends undefined ? () => void : (payload: T) => void;
|
|
18
|
+
|
|
19
|
+
/** A typed event bus over an event→payload map `M`. Methods are arrow-function
|
|
20
|
+
* properties (not method signatures) so consumers can safely destructure
|
|
21
|
+
* `const { on, emit } = bus` without unbound-`this` warnings. */
|
|
22
|
+
export interface Bus<M> {
|
|
23
|
+
/** Subscribe to an event. Returns an unsubscribe function. */
|
|
24
|
+
on: <K extends keyof M>(event: K, handler: BusHandler<M[K]>) => () => void;
|
|
25
|
+
/** Subscribe for exactly one emit, then auto-unsubscribe. Returns an
|
|
26
|
+
* unsubscribe function (to cancel before it fires). */
|
|
27
|
+
once: <K extends keyof M>(event: K, handler: BusHandler<M[K]>) => () => void;
|
|
28
|
+
/** Remove a previously-registered handler (no-op if absent). */
|
|
29
|
+
off: <K extends keyof M>(event: K, handler: BusHandler<M[K]>) => void;
|
|
30
|
+
/** Emit an event. Events whose payload is `undefined` are emitted with no
|
|
31
|
+
* payload argument. */
|
|
32
|
+
emit: <K extends keyof M>(
|
|
33
|
+
...args: M[K] extends undefined ? [event: K] : [event: K, payload: M[K]]
|
|
34
|
+
) => void;
|
|
35
|
+
/** Remove all handlers for one event, or all handlers entirely. */
|
|
36
|
+
clear: (event?: keyof M) => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
type AnyHandler = (payload?: unknown) => void;
|
|
40
|
+
|
|
41
|
+
interface Slot {
|
|
42
|
+
set: Set<AnyHandler>;
|
|
43
|
+
snapshot: AnyHandler[];
|
|
44
|
+
dirty: boolean;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Create a typed event bus. `onError` (default `console.error`) isolates a
|
|
48
|
+
* throwing handler so it can't break sibling handlers or the emitter. */
|
|
49
|
+
export function createBus<M>(options?: {
|
|
50
|
+
onError?: (event: keyof M, err: unknown) => void;
|
|
51
|
+
}): Bus<M> {
|
|
52
|
+
const slots = new Map<keyof M, Slot>();
|
|
53
|
+
const onError =
|
|
54
|
+
options?.onError ??
|
|
55
|
+
((event: keyof M, err: unknown): void => {
|
|
56
|
+
console.error(`[bus] handler error for "${String(event)}":`, err);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function slotFor(event: keyof M): Slot {
|
|
60
|
+
let slot = slots.get(event);
|
|
61
|
+
if (slot === undefined) {
|
|
62
|
+
slot = { set: new Set(), snapshot: [], dirty: false };
|
|
63
|
+
slots.set(event, slot);
|
|
64
|
+
}
|
|
65
|
+
return slot;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function on<K extends keyof M>(event: K, handler: BusHandler<M[K]>): () => void {
|
|
69
|
+
const slot = slotFor(event);
|
|
70
|
+
slot.set.add(handler as AnyHandler);
|
|
71
|
+
slot.dirty = true;
|
|
72
|
+
return (): void => {
|
|
73
|
+
off(event, handler);
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function off<K extends keyof M>(event: K, handler: BusHandler<M[K]>): void {
|
|
78
|
+
const slot = slots.get(event);
|
|
79
|
+
if (slot === undefined) {
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
if (slot.set.delete(handler as AnyHandler)) {
|
|
83
|
+
slot.dirty = true;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function once<K extends keyof M>(event: K, handler: BusHandler<M[K]>): () => void {
|
|
88
|
+
const wrapped = ((...callArgs: unknown[]): void => {
|
|
89
|
+
off(event, wrapped);
|
|
90
|
+
(handler as AnyHandler)(...callArgs);
|
|
91
|
+
}) as BusHandler<M[K]>;
|
|
92
|
+
return on(event, wrapped);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function emit<K extends keyof M>(
|
|
96
|
+
...args: M[K] extends undefined ? [event: K] : [event: K, payload: M[K]]
|
|
97
|
+
): void {
|
|
98
|
+
const [event, payload] = args as [K, unknown];
|
|
99
|
+
const slot = slots.get(event);
|
|
100
|
+
if (slot === undefined) {
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (slot.dirty) {
|
|
104
|
+
slot.snapshot = Array.from(slot.set);
|
|
105
|
+
slot.dirty = false;
|
|
106
|
+
}
|
|
107
|
+
// No-payload events (`emit(event)` with no second arg) invoke handlers with
|
|
108
|
+
// zero arguments, honoring the `() => void` handler type — a `() => void`
|
|
109
|
+
// handler should never receive a stray `undefined`.
|
|
110
|
+
const hasPayload = args.length > 1;
|
|
111
|
+
for (const handler of slot.snapshot) {
|
|
112
|
+
try {
|
|
113
|
+
if (hasPayload) {
|
|
114
|
+
handler(payload);
|
|
115
|
+
} else {
|
|
116
|
+
handler();
|
|
117
|
+
}
|
|
118
|
+
} catch (err) {
|
|
119
|
+
onError(event, err);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function clear(event?: keyof M): void {
|
|
125
|
+
if (event === undefined) {
|
|
126
|
+
slots.clear();
|
|
127
|
+
} else {
|
|
128
|
+
slots.delete(event);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { on, once, off, emit, clear };
|
|
133
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// collection.ts — reactive ordered collection of keyed entities.
|
|
2
|
+
//
|
|
3
|
+
// Generalizes the "list of entities, each independently reactive, plus a
|
|
4
|
+
// separate structure signal" model: a per-entity content update touches only
|
|
5
|
+
// that entity's subscribers, while add/remove/reorder bumps the `ids` signal.
|
|
6
|
+
// This is the data half of the two-tier list pattern (see `bindList` for the
|
|
7
|
+
// render half). Built on `signal` + `SignalMap` — no separate engine.
|
|
8
|
+
|
|
9
|
+
import { signal, batch, type ReadonlySignal } from "./signal.js";
|
|
10
|
+
import { SignalMap } from "./signal-map.js";
|
|
11
|
+
|
|
12
|
+
/** A reactive, ordered, keyed collection of entities of type T. */
|
|
13
|
+
export interface Collection<T> {
|
|
14
|
+
/** Replace the whole collection (e.g. initial load). Entities no longer
|
|
15
|
+
* present are dropped; the order becomes exactly `items`. */
|
|
16
|
+
setAll(items: readonly T[]): void;
|
|
17
|
+
/** Add a new entity or replace an existing one (keyed by `keyOf`). New
|
|
18
|
+
* entities are appended to the order. */
|
|
19
|
+
upsert(item: T): void;
|
|
20
|
+
/** Add new entities to the FRONT of the order (e.g. loading older items for
|
|
21
|
+
* scroll-up pagination). Existing ids are updated in place without moving. */
|
|
22
|
+
prepend(items: readonly T[]): void;
|
|
23
|
+
/** Update one entity in place (no-op if absent). Pass a value or an
|
|
24
|
+
* updater. Does not change order. */
|
|
25
|
+
update(id: string, next: T | ((cur: T) => T)): void;
|
|
26
|
+
/** Remove one entity (no-op if absent). */
|
|
27
|
+
remove(id: string): void;
|
|
28
|
+
/** Remove every entity. */
|
|
29
|
+
clear(): void;
|
|
30
|
+
/** Current value of one entity (untracked peek), or undefined. */
|
|
31
|
+
get(id: string): T | undefined;
|
|
32
|
+
/** Whether an entity is present (untracked). */
|
|
33
|
+
has(id: string): boolean;
|
|
34
|
+
/** Reactive per-entity signal, or undefined if absent. Read inside an effect
|
|
35
|
+
* to react only when THIS entity changes (the content tier). */
|
|
36
|
+
signalFor(id: string): ReadonlySignal<T> | undefined;
|
|
37
|
+
/** Reactive ordered id list — changes on add/remove/reorder, NOT on a
|
|
38
|
+
* per-entity content update (the structure tier). */
|
|
39
|
+
readonly ids: ReadonlySignal<readonly string[]>;
|
|
40
|
+
/** Ordered snapshot of entity values. Reactive: reading this inside an
|
|
41
|
+
* effect tracks order AND every entity (coarse — use `ids` + `signalFor`
|
|
42
|
+
* for the two-tier split). */
|
|
43
|
+
items(): T[];
|
|
44
|
+
/** Entity count (untracked). */
|
|
45
|
+
readonly size: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Create a reactive ordered keyed collection. `keyOf` derives a stable
|
|
49
|
+
* string id from an entity. */
|
|
50
|
+
export function createCollection<T>(keyOf: (item: T) => string): Collection<T> {
|
|
51
|
+
// Shallow-equal so the structure tier fires only when the id sequence
|
|
52
|
+
// actually changes (a same-order setAll updates entities without a
|
|
53
|
+
// spurious structural re-render).
|
|
54
|
+
const order = signal<readonly string[]>([], {
|
|
55
|
+
equals: (a, b) => a.length === b.length && a.every((x, i) => x === b[i]),
|
|
56
|
+
});
|
|
57
|
+
const sigs = new SignalMap<T>();
|
|
58
|
+
|
|
59
|
+
function setAll(items: readonly T[]): void {
|
|
60
|
+
// Atomic: batch the per-entity writes + the order swap so effects (and any
|
|
61
|
+
// row update that cross-reads siblings) only run once the whole collection
|
|
62
|
+
// is consistent, not mid-mutation.
|
|
63
|
+
batch(() => {
|
|
64
|
+
const ids: string[] = [];
|
|
65
|
+
const seen = new Set<string>();
|
|
66
|
+
for (const item of items) {
|
|
67
|
+
const id = keyOf(item);
|
|
68
|
+
ids.push(id);
|
|
69
|
+
seen.add(id);
|
|
70
|
+
sigs.ensure(id, item).value = item;
|
|
71
|
+
}
|
|
72
|
+
for (const id of order.peek()) {
|
|
73
|
+
if (!seen.has(id)) {
|
|
74
|
+
sigs.clear(id);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
order.value = ids;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function upsert(item: T): void {
|
|
82
|
+
const id = keyOf(item);
|
|
83
|
+
const existing = sigs.get(id);
|
|
84
|
+
if (existing === undefined) {
|
|
85
|
+
sigs.ensure(id, item);
|
|
86
|
+
order.value = [...order.peek(), id];
|
|
87
|
+
} else {
|
|
88
|
+
existing.value = item;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function prepend(items: readonly T[]): void {
|
|
93
|
+
batch(() => {
|
|
94
|
+
const newIds: string[] = [];
|
|
95
|
+
for (const item of items) {
|
|
96
|
+
const id = keyOf(item);
|
|
97
|
+
const existing = sigs.get(id);
|
|
98
|
+
if (existing === undefined) {
|
|
99
|
+
sigs.ensure(id, item);
|
|
100
|
+
newIds.push(id);
|
|
101
|
+
} else {
|
|
102
|
+
existing.value = item;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (newIds.length > 0) {
|
|
106
|
+
order.value = [...newIds, ...order.peek()];
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function update(id: string, next: T | ((cur: T) => T)): void {
|
|
112
|
+
const s = sigs.get(id);
|
|
113
|
+
if (s === undefined) {
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
s.value = typeof next === "function" ? (next as (cur: T) => T)(s.peek()) : next;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function remove(id: string): void {
|
|
120
|
+
if (sigs.get(id) === undefined) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
sigs.clear(id);
|
|
124
|
+
order.value = order.peek().filter((x) => x !== id);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function clear(): void {
|
|
128
|
+
sigs.clearAll();
|
|
129
|
+
order.value = [];
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function get(id: string): T | undefined {
|
|
133
|
+
return sigs.get(id)?.peek();
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function has(id: string): boolean {
|
|
137
|
+
return sigs.get(id) !== undefined;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function signalFor(id: string): ReadonlySignal<T> | undefined {
|
|
141
|
+
return sigs.get(id);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function items(): T[] {
|
|
145
|
+
const result: T[] = [];
|
|
146
|
+
for (const id of order.value) {
|
|
147
|
+
const s = sigs.get(id);
|
|
148
|
+
if (s !== undefined) {
|
|
149
|
+
result.push(s.value);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
setAll,
|
|
157
|
+
upsert,
|
|
158
|
+
prepend,
|
|
159
|
+
update,
|
|
160
|
+
remove,
|
|
161
|
+
clear,
|
|
162
|
+
get,
|
|
163
|
+
has,
|
|
164
|
+
signalFor,
|
|
165
|
+
items,
|
|
166
|
+
get ids(): ReadonlySignal<readonly string[]> {
|
|
167
|
+
return order;
|
|
168
|
+
},
|
|
169
|
+
get size(): number {
|
|
170
|
+
return order.peek().length;
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
package/src/el.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// el.ts — CSP-safe DOM element factory. The build half of the DOM layer; pairs
|
|
2
|
+
// with reconcile/patch (the commit half). `on*` handlers are assigned as
|
|
3
|
+
// properties AND registered via trackHandler, so patch() reconciles them across
|
|
4
|
+
// re-renders. No innerHTML, no string templating — safe under a strict CSP.
|
|
5
|
+
|
|
6
|
+
import { trackHandler } from "./reconcile-tree.js";
|
|
7
|
+
|
|
8
|
+
/** A value for an element attribute/property/handler. Functions are treated as
|
|
9
|
+
* event handlers when the key starts with `on`. */
|
|
10
|
+
export type AttrValue =
|
|
11
|
+
| string
|
|
12
|
+
| number
|
|
13
|
+
| boolean
|
|
14
|
+
| null
|
|
15
|
+
| undefined
|
|
16
|
+
| ((...args: never[]) => unknown);
|
|
17
|
+
|
|
18
|
+
// Keys set as DOM *properties* (not attributes) because the attribute form
|
|
19
|
+
// either doesn't reflect (value/checked) or is awkward (boolean presence).
|
|
20
|
+
const BOOL_PROPS = new Set([
|
|
21
|
+
"hidden",
|
|
22
|
+
"disabled",
|
|
23
|
+
"checked",
|
|
24
|
+
"selected",
|
|
25
|
+
"multiple",
|
|
26
|
+
"readOnly",
|
|
27
|
+
"required",
|
|
28
|
+
"open",
|
|
29
|
+
"default",
|
|
30
|
+
]);
|
|
31
|
+
const VALUE_PROPS = new Set(["value", "colSpan", "rowSpan", "tabIndex", "htmlFor"]);
|
|
32
|
+
|
|
33
|
+
/** Create an element: `el("button", { className: "x", onclick: fn }, "Label")`.
|
|
34
|
+
* - `className` → the class property
|
|
35
|
+
* - `on*` → handler property + `trackHandler` (reconciled by patch)
|
|
36
|
+
* - boolean DOM props (hidden/disabled/checked/selected/…) → property
|
|
37
|
+
* - value/colSpan/rowSpan/tabIndex/htmlFor → property
|
|
38
|
+
* - everything else → `setAttribute` (incl. `data-*`, `aria-*`, `style`, `id`)
|
|
39
|
+
* Null/undefined attrs and children are skipped. String children become text
|
|
40
|
+
* nodes (never parsed as HTML). */
|
|
41
|
+
export function el(
|
|
42
|
+
tag: string,
|
|
43
|
+
attrs?: Record<string, AttrValue> | null,
|
|
44
|
+
...children: (string | Node | null | undefined)[]
|
|
45
|
+
): HTMLElement {
|
|
46
|
+
const e = document.createElement(tag);
|
|
47
|
+
if (attrs) {
|
|
48
|
+
for (const [k, v] of Object.entries(attrs)) {
|
|
49
|
+
if (v === null || v === undefined) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (k === "className") {
|
|
53
|
+
e.className = v as string;
|
|
54
|
+
} else if (k.startsWith("on")) {
|
|
55
|
+
(e as unknown as Record<string, unknown>)[k] = v;
|
|
56
|
+
trackHandler(e, k);
|
|
57
|
+
} else if (BOOL_PROPS.has(k)) {
|
|
58
|
+
(e as unknown as Record<string, unknown>)[k] = v;
|
|
59
|
+
} else if (VALUE_PROPS.has(k)) {
|
|
60
|
+
(e as unknown as Record<string, unknown>)[k] = v;
|
|
61
|
+
} else {
|
|
62
|
+
e.setAttribute(k, String(v));
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
for (const child of children) {
|
|
67
|
+
if (child === null || child === undefined) {
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
e.appendChild(typeof child === "string" ? document.createTextNode(child) : child);
|
|
71
|
+
}
|
|
72
|
+
return e;
|
|
73
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -20,6 +20,15 @@ export type {
|
|
|
20
20
|
} from "./signal.js";
|
|
21
21
|
export { createStore } from "./store.js";
|
|
22
22
|
export type { Store } from "./store.js";
|
|
23
|
+
export { SignalMap } from "./signal-map.js";
|
|
24
|
+
export { createCollection } from "./collection.js";
|
|
25
|
+
export type { Collection } from "./collection.js";
|
|
26
|
+
export { bindList } from "./bind-list.js";
|
|
27
|
+
export type { ListSpec, ListSource } from "./bind-list.js";
|
|
28
|
+
export { createBus } from "./bus.js";
|
|
29
|
+
export type { Bus, BusHandler } from "./bus.js";
|
|
30
|
+
export { el } from "./el.js";
|
|
31
|
+
export type { AttrValue } from "./el.js";
|
|
23
32
|
export { reconcile, KEY_ATTR } from "./reconcile.js";
|
|
24
33
|
export type { ReconcileSpec } from "./reconcile.js";
|
|
25
34
|
export { patch, reconcileChildren, trackHandler } from "./reconcile-tree.js";
|
package/src/reconcile-tree.ts
CHANGED
|
@@ -124,7 +124,7 @@ function nodeKey(node: Node): string {
|
|
|
124
124
|
return "";
|
|
125
125
|
}
|
|
126
126
|
for (const attr of (node as Element).attributes) {
|
|
127
|
-
if (attr.name.endsWith("-id")) {
|
|
127
|
+
if (attr.name === "data-col" || attr.name.endsWith("-id")) {
|
|
128
128
|
return `${attr.name}=${attr.value}`;
|
|
129
129
|
}
|
|
130
130
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// SignalMap — a registry of lazily-created signals keyed by string ID.
|
|
2
|
+
//
|
|
3
|
+
// For dynamic, per-entity reactive state where the key set isn't known ahead
|
|
4
|
+
// of time (per-message streaming text, per-tool-call, per-row state, …). This
|
|
5
|
+
// complements `createStore`, whose typed key set is fixed at the type level.
|
|
6
|
+
// Like everything else here it's a facade over `signal` — no separate engine.
|
|
7
|
+
|
|
8
|
+
import { signal, type Signal } from "./signal.js";
|
|
9
|
+
|
|
10
|
+
/** A typed map of lazily-created signals keyed by string ID. Provides
|
|
11
|
+
* get / ensure / clear / clearAll over any value type. */
|
|
12
|
+
export class SignalMap<V> {
|
|
13
|
+
private readonly map = new Map<string, Signal<V>>();
|
|
14
|
+
|
|
15
|
+
/** The signal for `id`, or undefined if none has been created yet. */
|
|
16
|
+
get(id: string): Signal<V> | undefined {
|
|
17
|
+
return this.map.get(id);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The signal for `id`, creating it with `initial` on first access. */
|
|
21
|
+
ensure(id: string, initial: V): Signal<V> {
|
|
22
|
+
let sig = this.map.get(id);
|
|
23
|
+
if (sig === undefined) {
|
|
24
|
+
sig = signal(initial);
|
|
25
|
+
this.map.set(id, sig);
|
|
26
|
+
}
|
|
27
|
+
return sig;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Drop the signal for `id` (e.g. when the entity is removed). */
|
|
31
|
+
clear(id: string): void {
|
|
32
|
+
this.map.delete(id);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Drop every signal — e.g. on a wholesale context switch. */
|
|
36
|
+
clearAll(): void {
|
|
37
|
+
this.map.clear();
|
|
38
|
+
}
|
|
39
|
+
}
|
package/src/store.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
|
-
// Typed per-key reactive store
|
|
1
|
+
// Typed per-key reactive store — a thin facade over the signal engine.
|
|
2
|
+
//
|
|
3
|
+
// Each key is backed by a lazily-created signal, so the store inherits the
|
|
4
|
+
// engine's glitch-freedom, cycle detection, batching, and error handling.
|
|
5
|
+
// There is NO separate reactivity implementation here: get/set read and
|
|
6
|
+
// write signals, effect/batch are the engine's own, and auto-tracking falls
|
|
7
|
+
// out of reading `signal.value` inside an effect.
|
|
2
8
|
//
|
|
3
9
|
// Usage:
|
|
4
10
|
// import { createStore } from './store.js';
|
|
5
11
|
// interface MyMap { count: number; name: string }
|
|
6
12
|
// const { get, set, subscribe, effect, computed, batch } = createStore<MyMap>();
|
|
7
13
|
|
|
8
|
-
type
|
|
9
|
-
// eslint-disable-next-line @typescript-eslint/no-invalid-void-type -- void is intentional for effect return
|
|
10
|
-
type Cleanup = void | (() => void);
|
|
14
|
+
import { signal, effect, batch, type Signal, type Cleanup } from "./signal.js";
|
|
11
15
|
|
|
12
16
|
/** A typed per-key reactive store with auto-tracked effects, computed, and batching. */
|
|
13
17
|
export interface Store<M> {
|
|
@@ -19,141 +23,51 @@ export interface Store<M> {
|
|
|
19
23
|
batch(fn: () => void): void;
|
|
20
24
|
}
|
|
21
25
|
|
|
22
|
-
/** Create a typed reactive store. Keys are
|
|
26
|
+
/** Create a typed reactive store. Keys are lazily backed by signals; reading a
|
|
27
|
+
* key inside an effect auto-tracks it, and writes notify through the engine. */
|
|
23
28
|
export function createStore<M>(): Store<M> {
|
|
24
|
-
const
|
|
25
|
-
const subscribers: Record<string, Callback[]> = {};
|
|
26
|
-
|
|
27
|
-
let tracking: Set<string> | null = null;
|
|
28
|
-
let batchDepth = 0;
|
|
29
|
-
let pendingKeys: Map<string, unknown> | null = null;
|
|
29
|
+
const sigs = new Map<string, Signal<unknown>>();
|
|
30
30
|
|
|
31
|
-
function
|
|
32
|
-
|
|
33
|
-
if (
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
try {
|
|
38
|
-
cb(value);
|
|
39
|
-
} catch (e) {
|
|
40
|
-
console.error("store subscriber error:", e);
|
|
41
|
-
}
|
|
31
|
+
function sigFor<K extends keyof M & string>(key: K): Signal<M[K]> {
|
|
32
|
+
let s = sigs.get(key);
|
|
33
|
+
if (s === undefined) {
|
|
34
|
+
// Unset keys read as `undefined` (matching a sparse record) until first set.
|
|
35
|
+
s = signal<unknown>(undefined);
|
|
36
|
+
sigs.set(key, s);
|
|
42
37
|
}
|
|
38
|
+
return s as Signal<M[K]>;
|
|
43
39
|
}
|
|
44
40
|
|
|
45
41
|
function get<K extends keyof M & string>(key: K): M[K] {
|
|
46
|
-
|
|
47
|
-
tracking.add(key);
|
|
48
|
-
}
|
|
49
|
-
return state[key] as M[K];
|
|
42
|
+
return sigFor(key).value;
|
|
50
43
|
}
|
|
51
44
|
|
|
52
45
|
function set<K extends keyof M & string>(key: K, value: M[K]): void {
|
|
53
|
-
|
|
54
|
-
state[key] = value;
|
|
55
|
-
if (Object.is(prev, value)) {
|
|
56
|
-
return;
|
|
57
|
-
}
|
|
58
|
-
if (batchDepth > 0) {
|
|
59
|
-
pendingKeys ??= new Map();
|
|
60
|
-
pendingKeys.set(key, value);
|
|
61
|
-
} else {
|
|
62
|
-
notifySubs(key, value);
|
|
63
|
-
}
|
|
46
|
+
sigFor(key).value = value;
|
|
64
47
|
}
|
|
65
48
|
|
|
49
|
+
// Notify on change only (not immediately on subscribe) — the engine's
|
|
50
|
+
// `subscribe` fires immediately, so skip the initial effect run.
|
|
66
51
|
function subscribe<K extends keyof M & string>(key: K, cb: (value: M[K]) => void): () => void {
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return () => {
|
|
70
|
-
const
|
|
71
|
-
if (
|
|
72
|
-
|
|
73
|
-
}
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function storeBatch(fn: () => void): void {
|
|
78
|
-
batchDepth++;
|
|
79
|
-
try {
|
|
80
|
-
fn();
|
|
81
|
-
} finally {
|
|
82
|
-
batchDepth--;
|
|
83
|
-
if (batchDepth === 0 && pendingKeys) {
|
|
84
|
-
const p = pendingKeys;
|
|
85
|
-
pendingKeys = null;
|
|
86
|
-
for (const [k, v] of p) {
|
|
87
|
-
notifySubs(k, v);
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
function storeEffect(fn: () => Cleanup): () => void {
|
|
94
|
-
let unsubs: (() => void)[] = [];
|
|
95
|
-
let cleanup: Cleanup;
|
|
96
|
-
let disposed = false;
|
|
97
|
-
|
|
98
|
-
const run = (): void => {
|
|
99
|
-
if (disposed) {
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
for (const u of unsubs) {
|
|
103
|
-
u();
|
|
104
|
-
}
|
|
105
|
-
unsubs = [];
|
|
106
|
-
if (cleanup) {
|
|
107
|
-
const c = cleanup;
|
|
108
|
-
cleanup = undefined;
|
|
109
|
-
try {
|
|
110
|
-
c();
|
|
111
|
-
} catch (e) {
|
|
112
|
-
console.error("store effect cleanup error:", e);
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
const prev = tracking;
|
|
116
|
-
tracking = new Set();
|
|
117
|
-
try {
|
|
118
|
-
cleanup = fn();
|
|
119
|
-
} catch (e) {
|
|
120
|
-
console.error("store effect error:", e);
|
|
121
|
-
}
|
|
122
|
-
const deps = tracking;
|
|
123
|
-
tracking = prev;
|
|
124
|
-
for (const dep of deps) {
|
|
125
|
-
unsubs.push(
|
|
126
|
-
subscribe(dep as keyof M & string, run as (value: M[keyof M & string]) => void),
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
};
|
|
130
|
-
run();
|
|
131
|
-
return () => {
|
|
132
|
-
if (disposed) {
|
|
52
|
+
const s = sigFor(key);
|
|
53
|
+
let primed = false;
|
|
54
|
+
return effect(() => {
|
|
55
|
+
const v = s.value;
|
|
56
|
+
if (!primed) {
|
|
57
|
+
primed = true;
|
|
133
58
|
return;
|
|
134
59
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
u();
|
|
138
|
-
}
|
|
139
|
-
unsubs = [];
|
|
140
|
-
if (cleanup) {
|
|
141
|
-
const c = cleanup;
|
|
142
|
-
cleanup = undefined;
|
|
143
|
-
try {
|
|
144
|
-
c();
|
|
145
|
-
} catch (e) {
|
|
146
|
-
console.error("store effect cleanup error:", e);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
};
|
|
60
|
+
cb(v);
|
|
61
|
+
});
|
|
150
62
|
}
|
|
151
63
|
|
|
64
|
+
// A derived key: an effect that writes `outputKey` from `fn`. If `fn` reads
|
|
65
|
+
// `outputKey` the engine throws "Cycle detected" rather than looping.
|
|
152
66
|
function computed<K extends keyof M & string>(outputKey: K, fn: () => M[K]): () => void {
|
|
153
|
-
return
|
|
67
|
+
return effect(() => {
|
|
154
68
|
set(outputKey, fn());
|
|
155
69
|
});
|
|
156
70
|
}
|
|
157
71
|
|
|
158
|
-
return { get, set, subscribe, effect
|
|
72
|
+
return { get, set, subscribe, effect, computed, batch };
|
|
159
73
|
}
|