@cplieger/reactive 1.0.7 → 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 CHANGED
@@ -5,9 +5,9 @@
5
5
  [![JSR](https://jsr.io/badges/@cplieger/reactive)](https://jsr.io/@cplieger/reactive)
6
6
  [![License: GPL-3.0](https://img.shields.io/badge/License-GPL--3.0-blue.svg)](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 primitives library providing fine-grained signals with automatic dependency tracking, synchronous batched effects, keyed-list DOM reconciliation, structural tree-diffing, and a typed per-key reactive store. Zero dependencies beyond the DOM API.
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 Reconciliation
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
- - `createStore<M>(): Store<M>` — typed per-key reactive store with `get`, `set`, `subscribe`, `effect`, `computed`, and `batch` methods
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/reactive",
3
- "version": "1.0.7",
3
+ "version": "1.1.0",
4
4
  "exports": {
5
5
  ".": "./src/index.ts"
6
6
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cplieger/reactive",
3
- "version": "1.0.7",
3
+ "version": "1.1.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "exports": {
@@ -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";
@@ -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 with auto-tracked effects, computed, and batching.
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 Callback = (value: unknown) => void;
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 auto-tracked in effects; writes notify subscribers. */
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 state: Record<string, unknown> = {};
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 notifySubs(key: string, value: unknown): void {
32
- const cbs = subscribers[key];
33
- if (!cbs) {
34
- return;
35
- }
36
- for (const cb of cbs) {
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
- if (tracking) {
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
- const prev = state[key];
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
- subscribers[key] ??= [];
68
- subscribers[key].push(cb as Callback);
69
- return () => {
70
- const arr = subscribers[key];
71
- if (arr) {
72
- subscribers[key] = arr.filter((c) => c !== cb);
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
- disposed = true;
136
- for (const u of unsubs) {
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 storeEffect(() => {
67
+ return effect(() => {
154
68
  set(outputKey, fn());
155
69
  });
156
70
  }
157
71
 
158
- return { get, set, subscribe, effect: storeEffect, computed, batch: storeBatch };
72
+ return { get, set, subscribe, effect, computed, batch };
159
73
  }