sigula 1.0.4 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +281 -554
- package/dist/sigula.d.ts +687 -31
- package/dist/sigula.d.ts.map +1 -1
- package/dist/sigula.js +1 -1
- package/dist/sigula.js.map +1 -1
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -4,16 +4,28 @@
|
|
|
4
4
|
|
|
5
5
|
A minimal, signal-based web framework with fine-grained reactivity. No virtual DOM — just direct, minimal updates to the real DOM.
|
|
6
6
|
|
|
7
|
+
```ts
|
|
8
|
+
import {html, on, patch, render, sig, text} from 'sigula';
|
|
9
|
+
|
|
10
|
+
const count = sig(0);
|
|
11
|
+
|
|
12
|
+
render(
|
|
13
|
+
html`<p>${count}</p>
|
|
14
|
+
<button ${patch(on('click', () => count.trans((v) => v + 1)))}>+1</button>`,
|
|
15
|
+
document.querySelector('#app')!,
|
|
16
|
+
);
|
|
17
|
+
```
|
|
18
|
+
|
|
7
19
|
## ✨ Features
|
|
8
20
|
|
|
9
|
-
- Fine-grained signal reactivity — when
|
|
10
|
-
- Declarative
|
|
11
|
-
- No virtual DOM — no diffing, no
|
|
12
|
-
-
|
|
13
|
-
- Batched, coalesced updates — writes are queued in a microtask
|
|
14
|
-
- Ultra small — ~4.
|
|
15
|
-
- TypeScript
|
|
16
|
-
-
|
|
21
|
+
- **Fine-grained signal reactivity** — when a signal changes, only the DOM nodes that actually depend on it are updated, not the whole component tree.
|
|
22
|
+
- **Declarative sources + precise bindings** — describe state with signals, then bind them precisely to a text node, an attribute, or an effect.
|
|
23
|
+
- **No virtual DOM** — no VNodes, no diffing, no reconciliation pass. Direct real DOM operations.
|
|
24
|
+
- **Native string templates** — `html` is a tagged template over plain JavaScript strings. No custom compiler, no JSX transform, no `.vue` files.
|
|
25
|
+
- **Batched, coalesced updates** — writes are queued in a microtask; a signal touched many times before the flush runs each dependent binding once, against the final value.
|
|
26
|
+
- **Ultra small** — ~4.5KB minified + gzipped, with an API surface you can read in one sitting.
|
|
27
|
+
- **TypeScript first** — full type inference for signals, template bindings, and patch commands.
|
|
28
|
+
- **Zero tooling** — ESM-only, `sideEffects: false`, no build step required to author components.
|
|
17
29
|
|
|
18
30
|
## Installation
|
|
19
31
|
|
|
@@ -23,694 +35,404 @@ pnpm add sigula
|
|
|
23
35
|
yarn add sigula
|
|
24
36
|
```
|
|
25
37
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
```ts
|
|
29
|
-
import {compute, html, on, patch, render, sig, text, type View} from 'sigula';
|
|
30
|
-
|
|
31
|
-
const Equation = (): View => {
|
|
32
|
-
const x = sig(0);
|
|
33
|
-
const y = sig(1);
|
|
34
|
-
|
|
35
|
-
const s = {x, y};
|
|
36
|
-
const sum = compute(s, (v) => v.x + v.y);
|
|
37
|
-
const product = compute(s, (v) => v.x * v.y);
|
|
38
|
-
const diff = compute(s, (v) => v.x - v.y);
|
|
39
|
-
const quotient = compute(s, (v) => v.x / v.y);
|
|
40
|
-
|
|
41
|
-
const xSquare = compute(x, (v) => v * v);
|
|
42
|
-
const ySquare = compute(y, (v) => v * v);
|
|
43
|
-
|
|
44
|
-
const sumDiffProduct = compute({sum, diff}, (v) => v.sum * v.diff);
|
|
45
|
-
const squareDiff = compute({xSquare, ySquare}, (v) => v.xSquare - v.ySquare);
|
|
38
|
+
Sigula is ESM-only and ships type declarations. Importing the module is side-effect free; the DOM is only touched when you actually render.
|
|
46
39
|
|
|
47
|
-
|
|
48
|
-
<h1>Equation</h1>
|
|
49
|
-
<div>
|
|
50
|
-
<p>x: ${text(x)} -
|
|
51
|
-
<button ${patch(on('click', () => x.trans((v) => v + 1)))}>+1</button>
|
|
52
|
-
<button ${patch(on('click', () => x.trans((v) => v - 1)))}>-1</button>
|
|
53
|
-
</p>
|
|
54
|
-
<p>y: ${text(y)} -
|
|
55
|
-
<button ${patch(on('click', () => y.trans((v) => v + 1)))}>+1</button>
|
|
56
|
-
<button ${patch(on('click', () => y.trans((v) => v - 1)))}>-1</button>
|
|
57
|
-
</p>
|
|
58
|
-
<p>sum: x + y = ${text(x)} + ${text(y)} = ${text(sum)}</p>
|
|
59
|
-
<p>difference: x - y = ${text(x)} - ${text(y)} = ${text(diff)}</p>
|
|
60
|
-
<p>product: x * y = ${text(x)} * ${text(y)} = ${text(product)}</p>
|
|
61
|
-
<p>quotient: x / y = ${text(x)} / ${text(y)} = ${text(quotient)}</p>
|
|
62
|
-
<p>
|
|
63
|
-
(x + y)(x - y)
|
|
64
|
-
= (${text(x)} + ${text(y)})(${text(x)} - ${text(y)})
|
|
65
|
-
= ${text(sum)}*${text(diff)} = ${text(sumDiffProduct)}
|
|
66
|
-
<br />
|
|
67
|
-
= x^2 - y^2 = ${text(xSquare)} - ${text(ySquare)} = ${text(squareDiff)}
|
|
68
|
-
</p>
|
|
69
|
-
</div>
|
|
70
|
-
</div>`;
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const appNode = document.querySelector('#app');
|
|
74
|
-
if (!appNode) throw new Error('#app not found');
|
|
75
|
-
render(Equation(), appNode);
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
## 🧠 Core Concepts
|
|
79
|
-
|
|
80
|
-
### Fine-grained updates to the real DOM
|
|
40
|
+
## Quick Start
|
|
81
41
|
|
|
82
|
-
|
|
42
|
+
### 1. Render something
|
|
83
43
|
|
|
84
44
|
```ts
|
|
85
|
-
import {html,
|
|
45
|
+
import {html, render} from 'sigula';
|
|
86
46
|
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
const App = html`<h1>Hello, ${text(name)}!</h1>`;
|
|
47
|
+
const app = document.querySelector('#app')!;
|
|
90
48
|
|
|
91
|
-
|
|
92
|
-
name.update('Bob'); // → the text changes from "Hello, Alice!" to "Hello, Bob!"
|
|
49
|
+
render(html`<h1>Hello, world!</h1>`, app);
|
|
93
50
|
```
|
|
94
51
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
### Declarative signal sources + precise bindings
|
|
52
|
+
`html` returns a `View` — a real `DocumentFragment` plus the metadata Sigula needs to update it later. `render(view, node)` appends it and returns a disposer.
|
|
98
53
|
|
|
99
|
-
|
|
54
|
+
### 2. Make it reactive
|
|
100
55
|
|
|
101
56
|
```ts
|
|
102
|
-
import {
|
|
103
|
-
compute,
|
|
104
|
-
html,
|
|
105
|
-
on,
|
|
106
|
-
patch,
|
|
107
|
-
render,
|
|
108
|
-
repeat,
|
|
109
|
-
type Sig,
|
|
110
|
-
sig,
|
|
111
|
-
style,
|
|
112
|
-
text,
|
|
113
|
-
type View,
|
|
114
|
-
val,
|
|
115
|
-
view,
|
|
116
|
-
} from 'sigula';
|
|
117
|
-
|
|
118
|
-
interface Todo {
|
|
119
|
-
id: number;
|
|
120
|
-
text: string;
|
|
121
|
-
done: Sig<boolean>;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
const Todos = (): View => {
|
|
125
|
-
const input = sig('');
|
|
126
|
-
const todos = sig<Todo[]>([]);
|
|
127
|
-
const filter = sig<'all' | 'active' | 'done'>('all');
|
|
128
|
-
|
|
129
|
-
// Derived Signals
|
|
130
|
-
const visibleTodos = compute({todos, filter}, (v) => {
|
|
131
|
-
switch (v.filter) {
|
|
132
|
-
case 'active':
|
|
133
|
-
return v.todos.filter((t) => !t.done.get());
|
|
134
|
-
case 'done':
|
|
135
|
-
return v.todos.filter((t) => t.done.get());
|
|
136
|
-
default:
|
|
137
|
-
return v.todos;
|
|
138
|
-
}
|
|
139
|
-
});
|
|
140
|
-
|
|
141
|
-
const isEmpty = compute(visibleTodos, (v) => v.length <= 0);
|
|
142
|
-
|
|
143
|
-
const addTodo = (e: Event) => {
|
|
144
|
-
e.preventDefault();
|
|
145
|
-
if (!input.get().trim()) return;
|
|
57
|
+
import {html, render, sig} from 'sigula';
|
|
146
58
|
|
|
147
|
-
|
|
148
|
-
...items,
|
|
149
|
-
{id: Date.now(), text: input.get().trim(), done: sig(false)},
|
|
150
|
-
]);
|
|
151
|
-
input.update('');
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
const remove = (id: number) => {
|
|
155
|
-
todos.trans((items) => items.filter((item) => item.id !== id));
|
|
156
|
-
};
|
|
59
|
+
const name = sig('Alice');
|
|
157
60
|
|
|
158
|
-
|
|
159
|
-
<span
|
|
160
|
-
${patch(
|
|
161
|
-
on('click', () => item.done.trans((v) => !v)),
|
|
162
|
-
style(
|
|
163
|
-
compute(item.done, (v): string => (v ? 'line-through' : 'none')),
|
|
164
|
-
'textDecoration',
|
|
165
|
-
),
|
|
166
|
-
)}
|
|
167
|
-
>${text(item.text)}</span>
|
|
168
|
-
<button ${patch(on('click', () => remove(item.id)))}>x</button>
|
|
169
|
-
</li>`;
|
|
170
|
-
|
|
171
|
-
// Bind precisely to the DOM
|
|
172
|
-
return html`<div style="margin: 2rem auto; max-width: 400px">
|
|
173
|
-
<h1>Todos</h1>
|
|
174
|
-
<form ${patch(on('submit', addTodo))}>
|
|
175
|
-
<input ${patch(
|
|
176
|
-
val(input),
|
|
177
|
-
on('change', (e) => {
|
|
178
|
-
if (e.target) input.update((e.target as HTMLInputElement).value);
|
|
179
|
-
}),
|
|
180
|
-
)} />
|
|
181
|
-
<button>Add</button>
|
|
182
|
-
</form>
|
|
183
|
-
<div>
|
|
184
|
-
filter:
|
|
185
|
-
<button ${patch(on('click', () => filter.update('all')))}>all</button>
|
|
186
|
-
<button ${patch(on('click', () => filter.update('active')))}>active</button>
|
|
187
|
-
<button ${patch(on('click', () => filter.update('done')))}>done</button>
|
|
188
|
-
</div>
|
|
189
|
-
${view(isEmpty, (v) =>
|
|
190
|
-
v
|
|
191
|
-
? text('empty')
|
|
192
|
-
: html`<ul>${repeat(visibleTodos, {
|
|
193
|
-
key: (item) => item.id.toString(),
|
|
194
|
-
view: (item) => itemView(item),
|
|
195
|
-
})}</ul>`,
|
|
196
|
-
)}
|
|
197
|
-
</div>`;
|
|
198
|
-
};
|
|
61
|
+
render(html`<h1>Hello, ${name}!</h1>`, app);
|
|
199
62
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
render(Todos(), appNode);
|
|
63
|
+
// Only the text node inside <h1> is updated.
|
|
64
|
+
name.update('Bob'); // → "Hello, Bob!"
|
|
203
65
|
```
|
|
204
66
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
### No virtual DOM
|
|
208
|
-
|
|
209
|
-
Sigula does not create VNodes and does not diff trees. During the initial mount, the template establishes direct subscriptions between signals and DOM nodes. Every subsequent signal change goes straight to the corresponding DOM node.
|
|
67
|
+
A `Sig` interpolated in a **content position** is upgraded to a `text()` view automatically, so `${name}` and `${text(name)}` are equivalent.
|
|
210
68
|
|
|
211
|
-
|
|
212
|
-
- No VNode creation or destruction overhead
|
|
213
|
-
- No diffing traversal cost
|
|
214
|
-
- Memory usage scales linearly with DOM nodes, not with component tree depth
|
|
69
|
+
### 3. Handle events and patch attributes
|
|
215
70
|
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
Templates are plain JavaScript native string templates. No custom compiler, no `.vue` files, no JSX transform:
|
|
71
|
+
Dynamic values in an **attribute position** must be wrapped in `patch(...)`:
|
|
219
72
|
|
|
220
73
|
```ts
|
|
221
|
-
|
|
222
|
-
<div class="card">
|
|
223
|
-
<h2>${text(title)}</h2>
|
|
224
|
-
<p>${text(description)}</p>
|
|
225
|
-
<button ${patch(on('click', handleClick))}>Click me</button>
|
|
226
|
-
</div>
|
|
227
|
-
`;
|
|
228
|
-
```
|
|
229
|
-
|
|
230
|
-
`html` is a tagged template function that returns a mountable template (`View`) object. You can use any editor's syntax highlighting, formatting, and ESLint rules — no extra tooling required. Templates are cached per call site, so rendering the same template twice only parses it once.
|
|
231
|
-
|
|
232
|
-
### Ultra small
|
|
233
|
-
|
|
234
|
-
minified + gzipped: ~4.0KB
|
|
235
|
-
|
|
236
|
-
## 📖 Reference
|
|
74
|
+
import {compute, html, on, patch, render, sig, style, text} from 'sigula';
|
|
237
75
|
|
|
238
|
-
All exports are named exports from `sigula`.
|
|
239
|
-
|
|
240
|
-
- [Reactivity](#reactivity)
|
|
241
|
-
- [Templates](#templates)
|
|
242
|
-
- [DOM bindings](#dom-bindings)
|
|
243
|
-
- [Control flow](#control-flow)
|
|
244
|
-
- [Rendering](#rendering)
|
|
245
|
-
- [Low-level API](#low-level-api)
|
|
246
|
-
- [Errors](#errors)
|
|
247
|
-
- [Reactivity model](#reactivity-model)
|
|
248
|
-
|
|
249
|
-
### Reactivity
|
|
250
|
-
|
|
251
|
-
#### `sig`
|
|
252
|
-
|
|
253
|
-
```ts
|
|
254
|
-
const sig: <T>(v: T) => Sig<T>;
|
|
255
|
-
```
|
|
256
|
-
|
|
257
|
-
Creates a writable signal holding `v`.
|
|
258
|
-
|
|
259
|
-
```ts
|
|
260
76
|
const count = sig(0);
|
|
261
|
-
count
|
|
262
|
-
count.update(1); // schedules dependents
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
#### `Sig<T>`
|
|
266
|
-
|
|
267
|
-
The core reactive value.
|
|
77
|
+
const color = compute(count, (v) => (v >= 0 ? 'green' : 'red'));
|
|
268
78
|
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
| `equals` | `(other: unknown): boolean` | `Equatable` implementation; two `Sig`s are equal when their values are deeply equal. |
|
|
276
|
-
| `addBind` | `<C>(bind: Bind<T, C>): void` | Registers a binding. Prefer `createBind` / the `patch`/`text`/`view` APIs. |
|
|
277
|
-
| `removeBind` | `<C>(bind: Bind<T, C>): void` | Unregisters a binding; runs `cleanup()` when the last one goes away. |
|
|
278
|
-
| `getBinds` | `(): Bind<T, CmdContext>[]` | Returns the current bindings. |
|
|
279
|
-
| `cleanup` | `(): void` | Overridable hook called when a signal loses all bindings. No-op on `Sig`. |
|
|
280
|
-
|
|
281
|
-
#### `DerivedSig<T>`
|
|
282
|
-
|
|
283
|
-
A `Sig` produced by `compute`. Extends `Sig` and additionally tracks the source bindings that feed it. When it loses its last consumer it detaches from its sources; when a consumer is added again, it re-links to the (possibly moved) source signals and recomputes once.
|
|
284
|
-
|
|
285
|
-
| Member | Signature | Description |
|
|
286
|
-
| --- | --- | --- |
|
|
287
|
-
| `addFromBind` | `<S, C>(bind: Bind<S, C>): void` | Registers a source binding. |
|
|
288
|
-
| `addBind` | `<C>(bind: Bind<T, C>): void` | Registers a consumer; re-links to sources and recomputes once if the derived signal was detached. |
|
|
289
|
-
| `cleanup` | `(): void` | Removes every source binding when the derived signal has no consumers. |
|
|
290
|
-
|
|
291
|
-
#### `compute`
|
|
292
|
-
|
|
293
|
-
```ts
|
|
294
|
-
function compute<S, T>(source: Sig<S>, fn: (v: S) => T): DerivedSig<T>;
|
|
295
|
-
function compute<S extends SigRecord, T>(
|
|
296
|
-
source: S,
|
|
297
|
-
fn: (v: ValRecord<S>) => T,
|
|
298
|
-
): DerivedSig<T>;
|
|
79
|
+
render(
|
|
80
|
+
html`<p ${patch(style('color', color))}>${text(count)}</p>
|
|
81
|
+
<button ${patch(on('click', () => count.trans((v) => v + 1)))}>+1</button>
|
|
82
|
+
<button ${patch(on('click', () => count.trans((v) => v - 1)))}>-1</button>`,
|
|
83
|
+
app,
|
|
84
|
+
);
|
|
299
85
|
```
|
|
300
86
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
```ts
|
|
304
|
-
const x = sig(1);
|
|
305
|
-
const y = sig(2);
|
|
306
|
-
|
|
307
|
-
const sum = compute({x, y}, (v) => v.x + v.y); // DerivedSig<number>
|
|
308
|
-
const doubled = compute(x, (v) => v * 2); // DerivedSig<number>
|
|
309
|
-
```
|
|
87
|
+
### 4. Split into components
|
|
310
88
|
|
|
311
|
-
|
|
89
|
+
There is no component class, no lifecycle, no registration. **A component is just a function that returns a `View`.** It runs once, wires up bindings, and is never called again.
|
|
312
90
|
|
|
313
91
|
```ts
|
|
314
|
-
|
|
315
|
-
[key: string]: Sig<any>;
|
|
316
|
-
}
|
|
92
|
+
import {html, on, patch, render, sig, type View} from 'sigula';
|
|
317
93
|
|
|
318
|
-
|
|
319
|
-
|
|
94
|
+
const Counter = (initial: number): View => {
|
|
95
|
+
const count = sig(initial);
|
|
96
|
+
return html`<div>
|
|
97
|
+
<span>${count}</span>
|
|
98
|
+
<button ${patch(on('click', () => count.trans((v) => v + 1)))}>+1</button>
|
|
99
|
+
</div>`;
|
|
320
100
|
};
|
|
101
|
+
|
|
102
|
+
render(html`<main>${Counter(0)} ${Counter(100)}</main>`, app);
|
|
321
103
|
```
|
|
322
104
|
|
|
323
|
-
|
|
105
|
+
Because the function body runs exactly once, `sig(initial)` *is* the local state — no hooks, no `this`, no re-run semantics to reason about.
|
|
324
106
|
|
|
325
|
-
|
|
107
|
+
### 5. Render lists conditionally
|
|
326
108
|
|
|
327
109
|
```ts
|
|
328
|
-
|
|
329
|
-
```
|
|
110
|
+
import {compute, html, on, patch, render, repeat, sig, text, val, view} from 'sigula';
|
|
330
111
|
|
|
331
|
-
|
|
112
|
+
interface Todo { id: number; text: string; done: boolean }
|
|
332
113
|
|
|
333
|
-
|
|
114
|
+
const Todos = () => {
|
|
115
|
+
const input = sig('');
|
|
116
|
+
const todos = sig<Todo[]>([]);
|
|
117
|
+
const filter = sig<'all' | 'active'>('all');
|
|
334
118
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
```
|
|
119
|
+
const visible = compute({todos, filter}, (v) =>
|
|
120
|
+
v.filter === 'active' ? v.todos.filter((t) => !t.done) : v.todos,
|
|
121
|
+
);
|
|
122
|
+
const isEmpty = compute(visible, (v) => v.length === 0);
|
|
340
123
|
|
|
341
|
-
|
|
124
|
+
const add = (e: Event) => {
|
|
125
|
+
e.preventDefault();
|
|
126
|
+
if (!input.get().trim()) return;
|
|
127
|
+
todos.trans((items) => [...items, {id: Date.now(), text: input.get().trim(), done: false}]);
|
|
128
|
+
input.update('');
|
|
129
|
+
};
|
|
342
130
|
|
|
343
|
-
|
|
131
|
+
return html`<form ${patch(on('submit', add))}>
|
|
132
|
+
<input ${patch(val(input), on('change', (e) => input.update((e.target as HTMLInputElement).value)))} />
|
|
133
|
+
<button>Add</button>
|
|
134
|
+
</form>
|
|
135
|
+
${view(isEmpty, (empty) =>
|
|
136
|
+
empty
|
|
137
|
+
? text('Nothing here yet')
|
|
138
|
+
: html`<ul>${repeat(visible, {
|
|
139
|
+
key: (t) => t.id.toString(),
|
|
140
|
+
view: (t) => html`<li>${text(t.text)}</li>`,
|
|
141
|
+
})}</ul>`,
|
|
142
|
+
)}`;
|
|
143
|
+
};
|
|
344
144
|
|
|
345
|
-
|
|
346
|
-
|
|
145
|
+
const dispose = render(Todos(), app);
|
|
146
|
+
dispose(); // detaches every binding and removes the nodes
|
|
347
147
|
```
|
|
348
148
|
|
|
349
|
-
|
|
149
|
+
Runnable versions live in [`examples/`](./examples) (`equation`, `filtertodos`).
|
|
350
150
|
|
|
351
|
-
|
|
151
|
+
## 🧠 Core Concepts
|
|
352
152
|
|
|
353
|
-
|
|
354
|
-
const createBind: <T, C extends CmdContext>(
|
|
355
|
-
sig: Sig<T>,
|
|
356
|
-
context: C,
|
|
357
|
-
cmd: Cmd<T, C>,
|
|
358
|
-
) => Bind<T, C>;
|
|
153
|
+
### The whole architecture in one picture
|
|
359
154
|
|
|
360
|
-
const removeBind: <T, C extends CmdContext>(bind: Bind<T, C>) => void;
|
|
361
155
|
```
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
context
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
156
|
+
┌──────────────── Core Concepts ────────────────┐
|
|
157
|
+
│ │
|
|
158
|
+
Sig ──┤ holds a value + a list of Binds │ state
|
|
159
|
+
│ │
|
|
160
|
+
Bind ─┤ { sig, context, cmd } │ the edge
|
|
161
|
+
│ │
|
|
162
|
+
Cmd ──┤ (value, context) => void │ the work
|
|
163
|
+
│ │
|
|
164
|
+
View ─┤ { node, boundary(), cleanBinds() } │ DOM region
|
|
165
|
+
Patch ┤ deferred commands for one element │
|
|
166
|
+
│ │
|
|
167
|
+
Queue ┤ one global microtask, coalesced per Bind │ scheduling
|
|
168
|
+
└───────────────────────────────────────────────┘
|
|
375
169
|
```
|
|
376
170
|
|
|
377
|
-
|
|
171
|
+
Everything else in the library is a convenience layer over these five pieces. Read the rest as: *how do I create a `Bind`, and what `Cmd` should it run?*
|
|
378
172
|
|
|
379
|
-
|
|
173
|
+
### Signals: `sig`
|
|
380
174
|
|
|
381
|
-
|
|
382
|
-
const html: (
|
|
383
|
-
strs: TemplateStringsArray,
|
|
384
|
-
...items: (Patch | AnyView)[]
|
|
385
|
-
) => View;
|
|
386
|
-
```
|
|
387
|
-
|
|
388
|
-
Tagged template that parses native HTML and returns a `View`. Two kinds of interpolation are supported:
|
|
389
|
-
|
|
390
|
-
- a `View` (from `text`, `view`, `repeat`, or another `html`) fills a content position
|
|
391
|
-
- a `Patch` (from `patch(...)`) fills an attribute position
|
|
175
|
+
A `Sig<T>` is a value container that owns a list of **bindings**. It never touches the DOM itself.
|
|
392
176
|
|
|
393
177
|
```ts
|
|
394
|
-
|
|
395
|
-
html`<button ${patch(on('click', handler))}>Go</button>`;
|
|
396
|
-
```
|
|
397
|
-
|
|
398
|
-
Templates are cached per call site, so repeated renders skip parsing. Using `patch(...)` in a content position throws `E12`. An interpolation count that does not match the number of slots throws `E11:<expected>:<got>`; a mismatch means the cached template for that call site was built from a different mix of interpolations. See [Errors](#errors).
|
|
399
|
-
|
|
400
|
-
#### `text`
|
|
178
|
+
const count = sig(0);
|
|
401
179
|
|
|
402
|
-
|
|
403
|
-
|
|
180
|
+
count.get(); // 0 — read the current value
|
|
181
|
+
count.update(1); // set; dependents notified only if not deeply equal
|
|
182
|
+
count.update(1); // no-op: deeply equal, nothing is scheduled
|
|
183
|
+
count.forceUpdate(1); // set and always notify (even when equal)
|
|
184
|
+
count.trans((v) => v + 1);// apply a function to the current value
|
|
185
|
+
count.notify(); // re-run dependents without changing the value
|
|
404
186
|
```
|
|
405
187
|
|
|
406
|
-
|
|
188
|
+
| Method | Purpose |
|
|
189
|
+
| --- | --- |
|
|
190
|
+
| `get()` | Read the current value. |
|
|
191
|
+
| `update(v)` | Write, skipping the notification when `eq(v, current)`. |
|
|
192
|
+
| `forceUpdate(v)` | Write and always notify. Use after a structurally-equal-but-new value. |
|
|
193
|
+
| `trans(fn)` | `update(fn(current))` — the idiomatic way to derive the next state. |
|
|
194
|
+
| `notify()` | Re-run dependents against the current value. Use after mutating a held object/array **in place**. |
|
|
195
|
+
| `addBind` / `removeBind` / `getBinds` | Low-level binding management; prefer `createBind` or the template APIs. |
|
|
407
196
|
|
|
408
|
-
|
|
409
|
-
html`<span>${text(count)}</span>`;
|
|
410
|
-
```
|
|
197
|
+
**Equality is deep by default.** `update` compares with `eq`, a structural comparator covering primitives, arrays, `Date`, `RegExp`, `Map`, `Set` and plain objects, and delegating to `a.equals(b)` when the value implements `Equatable`. Replacing `{a: 1}` with another `{a: 1}` is therefore a no-op. Values with different prototypes are never equal. `sig(v, {eq})` accepts a custom comparator.
|
|
411
198
|
|
|
412
|
-
|
|
199
|
+
**In-place mutation needs `notify()`.** Sigula does not proxy your objects. If you mutate a held array or object instead of replacing it, the value identity never changes and `update` cannot see it:
|
|
413
200
|
|
|
414
201
|
```ts
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
bind?: Bind<T, C> | undefined;
|
|
419
|
-
cleanBinds: () => void;
|
|
420
|
-
boundary: () => Boundary;
|
|
421
|
-
children?: (AnyView | Patch)[];
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
type AnyView = View<any, any>;
|
|
202
|
+
const items = sig<string[]>([]);
|
|
203
|
+
items.get().push('a'); // value object mutated, no write detected
|
|
204
|
+
items.notify(); // ← tell dependents to re-run
|
|
425
205
|
```
|
|
426
206
|
|
|
427
|
-
|
|
207
|
+
### Bindings: the unit of reactivity
|
|
428
208
|
|
|
429
|
-
|
|
209
|
+
A binding is a three-field record — that is the entire reactive primitive:
|
|
430
210
|
|
|
431
211
|
```ts
|
|
432
|
-
|
|
212
|
+
interface Bind<T, C> {
|
|
213
|
+
sig: Sig<T>; // what it observes
|
|
214
|
+
context: C; // where the result goes (a text node, an element, …)
|
|
215
|
+
cmd: (val: T, ctx: C) => void; // what to do with the new value
|
|
216
|
+
removed: boolean;
|
|
217
|
+
queued?: boolean;
|
|
218
|
+
}
|
|
433
219
|
```
|
|
434
220
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
### DOM bindings
|
|
438
|
-
|
|
439
|
-
#### `patch`
|
|
221
|
+
So "fine-grained" is literal: `text(name)` creates a bind whose `context` is one `Text` node and whose `cmd` is `node.textContent = String(val)`. Nothing else in the tree is involved.
|
|
440
222
|
|
|
441
223
|
```ts
|
|
442
|
-
const
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
Declares one or more bindings to apply to the same element. Must be interpolated in an attribute position. Each command (`id`, `val`, `attr`, ...) receives either a plain value (applied once) or a `Sig` (applied on mount and re-applied on change).
|
|
224
|
+
const name = sig('Alice');
|
|
225
|
+
const v = text(name); // Bind{ sig: name, context: {node: <Text>}, cmd: textCmd }
|
|
446
226
|
|
|
447
|
-
|
|
448
|
-
html`<input ${patch(val(name), attr(placeholder, 'name'))} />`;
|
|
227
|
+
name.update('Bob'); // → textCmd('Bob', {node}) → that one node changes
|
|
449
228
|
```
|
|
450
229
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
```ts
|
|
454
|
-
const id: <T>(source: T | Sig<T>) => ToPatchItem<T>;
|
|
455
|
-
```
|
|
230
|
+
Because a `Cmd` is just a function, the same model covers DOM writes, derived values, and arbitrary side effects — there is no separate `effect()`/`watch()` API to learn.
|
|
456
231
|
|
|
457
|
-
|
|
232
|
+
### Derived signals: `compute`
|
|
458
233
|
|
|
459
|
-
|
|
234
|
+
`compute` returns a `DerivedSig<T>`, which is a `Sig` you cannot write to. Two overloads:
|
|
460
235
|
|
|
461
236
|
```ts
|
|
462
|
-
const
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
Sets the element's `value` property (form controls).
|
|
466
|
-
|
|
467
|
-
#### `attr`
|
|
237
|
+
const x = sig(1);
|
|
238
|
+
const doubled = compute(x, (v) => v * 2); // from one signal
|
|
468
239
|
|
|
469
|
-
|
|
470
|
-
const attr: <T>(source: T | Sig<T>, key: string) => ToPatchItem<T>;
|
|
240
|
+
const sum = compute({x, y}, (v) => v.x + v.y); // from a record of signals
|
|
471
241
|
```
|
|
472
242
|
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
#### `style`
|
|
243
|
+
With the record form, `fn` receives the matching record of *values* (`ValRecord<S>`), fully typed. Derived signals compose: a `DerivedSig` is a valid source for another `compute`, and a valid interpolation target in a template.
|
|
476
244
|
|
|
477
|
-
|
|
478
|
-
const style: <T>(
|
|
479
|
-
source: T | Sig<T>,
|
|
480
|
-
key: WritableStyleKey,
|
|
481
|
-
) => ToPatchItem<T>;
|
|
482
|
-
```
|
|
245
|
+
Derived signals are **lazy about upstream**. A `DerivedSig` detaches from its sources when it loses its last consumer (which happens whenever a `view()` subtree is hidden), and re-links and recomputes once when a consumer comes back. You get the memory savings without manual disposal.
|
|
483
246
|
|
|
484
|
-
|
|
247
|
+
### Templates: `html`
|
|
485
248
|
|
|
486
249
|
```ts
|
|
487
|
-
html
|
|
250
|
+
const html = (strs: TemplateStringsArray, ...rawItems: HtmlItem[]): View;
|
|
488
251
|
```
|
|
489
252
|
|
|
490
|
-
`
|
|
491
|
-
|
|
492
|
-
#### `styleProperty`
|
|
253
|
+
`html` is a tagged template over **native HTML strings** — no compiler, no DSL, no JSX pragma. Interpolations fall into two positions, and the distinction is the one rule to memorize:
|
|
493
254
|
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
255
|
+
| Position | What goes there | Example |
|
|
256
|
+
| --- | --- | --- |
|
|
257
|
+
| **Content** (child slot) | a `View`, `text`, `raw`, a `Sig`, or any primitive | `` html`<h1>${name}</h1>` `` |
|
|
258
|
+
| **Attribute** (inside a tag) | a `Patch` from `patch(...)` | `` html`<input ${patch(val(name))} />` `` |
|
|
497
259
|
|
|
498
|
-
|
|
260
|
+
Anything interpolated in a content position that is not already a `View` or `Patch` is coerced with `text()`, i.e. escaped and rendered as `String(value)`:
|
|
499
261
|
|
|
500
262
|
```ts
|
|
501
|
-
|
|
502
|
-
|
|
263
|
+
const sigItem = sig('signal item');
|
|
264
|
+
const strItem = 'string data';
|
|
265
|
+
const numItem = 2026;
|
|
266
|
+
const htmlItem = html`<span>Html Span Element</span>`;
|
|
503
267
|
|
|
504
|
-
|
|
268
|
+
render(
|
|
269
|
+
html`<p>${sigItem} / ${strItem} / ${numItem}</p>
|
|
270
|
+
<div>${htmlItem}</div>
|
|
271
|
+
<div>${raw('<strong>Trusted</strong> HTML')}</div>`,
|
|
272
|
+
app,
|
|
273
|
+
);
|
|
505
274
|
|
|
506
|
-
|
|
507
|
-
const toggleClass: <T>(source: T | Sig<T>, token: string) => ToPatchItem<T>;
|
|
275
|
+
sigItem.update('string with <strong>markup</strong>'); // stays escaped — renders as text
|
|
508
276
|
```
|
|
509
277
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
#### `toggleClasses`
|
|
278
|
+
`raw(source)` parses its value through a detached `<template>` and mounts the resulting nodes with no wrapper element. **It does not escape** — only ever pass trusted HTML.
|
|
513
279
|
|
|
514
|
-
|
|
515
|
-
const toggleClasses: <T>(
|
|
516
|
-
source: T | Sig<T>,
|
|
517
|
-
...tokens: string[]
|
|
518
|
-
) => ToPatchItem<T>;
|
|
519
|
-
```
|
|
280
|
+
#### How parsing works (and why it is fast)
|
|
520
281
|
|
|
521
|
-
|
|
282
|
+
Every call site gets a random marker, `@sig_<rand>`. Interpolations are rendered into the template string as:
|
|
522
283
|
|
|
523
|
-
|
|
284
|
+
- an **attribute marker** `@sig_x` for a `Patch`,
|
|
285
|
+
- a **comment marker** `<!--@sig_x-->` for a `View`.
|
|
524
286
|
|
|
525
|
-
```
|
|
526
|
-
|
|
527
|
-
const act: <T>(source: T | Sig<T>, fn: ActFn<T>) => ToPatchItem<T>;
|
|
287
|
+
```html
|
|
288
|
+
<div @sig_2734618> <!--@sig_2734618--> <!--@sig_2734618--> </div>
|
|
528
289
|
```
|
|
529
290
|
|
|
530
|
-
|
|
291
|
+
Therefore parsing needs no regular expressions: Sigula walks the parsed fragment with a single `TreeWalker`, collects each marker node in order, and commits the matching item (`commitPatch` for elements, `commitView` for comments). The uniform format is also what makes the API extensible — `id`, `on`, `attr`, `style` are all just patch items, and you can write your own.
|
|
531
292
|
|
|
532
|
-
|
|
533
|
-
html`<canvas ${patch(act(frame, (node, v) => draw(node, v)))}></canvas>`;
|
|
534
|
-
```
|
|
535
|
-
|
|
536
|
-
#### `on`
|
|
293
|
+
Two consequences worth knowing:
|
|
537
294
|
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
type: K,
|
|
541
|
-
listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => unknown,
|
|
542
|
-
options?: boolean | AddEventListenerOptions,
|
|
543
|
-
) => ToPatchItem<
|
|
544
|
-
(this: HTMLElement, ev: HTMLElementEventMap[K]) => unknown
|
|
545
|
-
>;
|
|
546
|
-
```
|
|
295
|
+
1. **One `Patch` per element.** The marker is an attribute, so a second `patch()` on the same element cannot be located. Combine everything into a single `patch(...)` call — that is what its variadic form is for.
|
|
296
|
+
2. **Templates are cached per call site** (a `WeakMap` on the `TemplateStringsArray`), and the cache key includes the *mix* of patch/view slots (a bitmask for up to 31 slots, a string beyond that). Repeated renders skip parsing entirely; a call site that changes its interpolation mix simply gets a fresh template.
|
|
547
297
|
|
|
548
|
-
|
|
298
|
+
The returned `View` is `{node, children, boundary(), cleanBinds()}` — see [Boundaries](#boundaries-and-teardown).
|
|
549
299
|
|
|
550
|
-
|
|
551
|
-
html`<button ${patch(on('click', () => count.trans((v) => v + 1)))}>+1</button>`;
|
|
552
|
-
```
|
|
300
|
+
### Patching an element: `patch`
|
|
553
301
|
|
|
554
|
-
|
|
302
|
+
`patch` declares bindings to apply to **one** element. It accepts either a props object, or a list of command items, or both:
|
|
555
303
|
|
|
556
304
|
```ts
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
extra?: unknown[];
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
interface PatchItem<T> {
|
|
563
|
-
source: T | Sig<T>;
|
|
564
|
-
context: PatchContext;
|
|
565
|
-
cmd: Cmd<T, PatchContext>;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
type ToPatchItem<T> = (el: Element) => PatchItem<T>;
|
|
569
|
-
type AnyPatchItem = PatchItem<any>;
|
|
570
|
-
type ToAnyPatchItem = (el: Element) => AnyPatchItem;
|
|
571
|
-
|
|
572
|
-
interface Patch {
|
|
573
|
-
type: 'patch';
|
|
574
|
-
toPatchItems: ToAnyPatchItem[];
|
|
575
|
-
cleanBinds: () => void;
|
|
576
|
-
}
|
|
577
|
-
```
|
|
578
|
-
|
|
579
|
-
`ToPatchItem` defers reading the target element until mount. `patch` collects these factories into a single `Patch`; `cleanBinds` detaches the bindings created when the patch was committed to an element.
|
|
580
|
-
|
|
581
|
-
### Control flow
|
|
582
|
-
|
|
583
|
-
#### `view`
|
|
305
|
+
// Props form — desugared into commands
|
|
306
|
+
html`<input ${patch({id: 'name', val: name, placeholder: 'Your name'})} />`
|
|
584
307
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
sig: Sig<T>,
|
|
588
|
-
viewFn: (val: T) => AnyView,
|
|
589
|
-
) => View<T>;
|
|
308
|
+
// Command form
|
|
309
|
+
html`<input ${patch(val(name), attr('placeholder', placeholder))} />`
|
|
590
310
|
```
|
|
591
311
|
|
|
592
|
-
|
|
312
|
+
The props object handles `id`, `val`, `class`, `style`, `styleProp`, `on`; any other key becomes an attribute. A key whose value is `undefined` is skipped.
|
|
593
313
|
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
314
|
+
| Command | What it does |
|
|
315
|
+
| --- | --- |
|
|
316
|
+
| `id(source)` | Sets `element.id`. |
|
|
317
|
+
| `val(source)` | Sets the `value` **property** (form controls). |
|
|
318
|
+
| `attr(key, source)` | `setAttribute(key, …)` — for boolean/ARIA/data attributes. |
|
|
319
|
+
| `style(key, source)` | Sets a typed inline style property. |
|
|
320
|
+
| `styleProp(key, source)` | `style.setProperty` — for `--custom-properties`. |
|
|
321
|
+
| `toggleClass(token, source)` | Toggles one class from the truthiness of the value. |
|
|
322
|
+
| `toggleClasses(tokens, source)` | Toggles several classes from one value. |
|
|
323
|
+
| `on(type, listener, options?)` | `addEventListener`. |
|
|
324
|
+
| `act(source, fn)` | Escape hatch: run arbitrary code with `(element, value)`. |
|
|
597
325
|
|
|
598
|
-
|
|
326
|
+
Every command takes a plain value (applied once at mount) **or** a `Sig` (applied at mount and re-applied on change):
|
|
599
327
|
|
|
600
328
|
```ts
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
view: (item: T) => AnyView;
|
|
604
|
-
compare?: (a: T, b: T) => boolean;
|
|
605
|
-
};
|
|
329
|
+
const disabled = sig(false);
|
|
330
|
+
const label = 'Submit';
|
|
606
331
|
|
|
607
|
-
|
|
332
|
+
html`<button ${patch(attr('disabled', disabled), attr('aria-label', label))}>Go</button>`
|
|
333
|
+
// ↑ reactive ↑ static
|
|
608
334
|
```
|
|
609
335
|
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
```ts
|
|
613
|
-
html`<ul>${repeat(todos, {
|
|
614
|
-
key: (item) => item.id.toString(),
|
|
615
|
-
view: (item) => html`<li>${text(item.label)}</li>`,
|
|
616
|
-
})}</ul>`;
|
|
617
|
-
```
|
|
336
|
+
Note that `on` registers the listener **once at mount**; the listener itself is not a reactive source. Drive updates by writing to a signal inside it.
|
|
618
337
|
|
|
619
|
-
|
|
338
|
+
### Control flow
|
|
620
339
|
|
|
621
|
-
|
|
340
|
+
Sigula has exactly four control-flow helpers, all returning a `View`:
|
|
622
341
|
|
|
623
|
-
|
|
342
|
+
| Helper | Use it for |
|
|
343
|
+
| --- | --- |
|
|
344
|
+
| `view(sig, viewFn)` | Swap one view for another when `sig` changes (conditional rendering). |
|
|
345
|
+
| `repeat(sig, {key, view, eq?})` | Keyed list rendering with minimal DOM reuse/moves. |
|
|
346
|
+
| `list(items, viewFn)` | A **static** array rendered once — no keying, no reconciliation. |
|
|
347
|
+
| `frag(...views)` | Compose several views as flat siblings with no wrapper element. |
|
|
624
348
|
|
|
625
349
|
```ts
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
node: Node,
|
|
629
|
-
) => () => void;
|
|
630
|
-
```
|
|
631
|
-
|
|
632
|
-
Mounts a view into `node` by appending `view.node`. Accepts a `View` directly or a factory function that returns one. Returns a disposer that detaches every bind in the tree and removes the nodes from `node`, so a mounted tree can be torn down completely. Calling the disposer twice is a no-op.
|
|
350
|
+
// conditional
|
|
351
|
+
${view(isEmpty, (empty) => (empty ? text('empty') : listView))}
|
|
633
352
|
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
353
|
+
// keyed list
|
|
354
|
+
${repeat(todos, {
|
|
355
|
+
key: (t) => t.id.toString(),
|
|
356
|
+
view: (t) => html`<li>${text(t.text)}</li>`,
|
|
357
|
+
eq: (a, b) => a.id === b.id && a.text === b.text, // optional, defaults to eq
|
|
358
|
+
})}
|
|
638
359
|
```
|
|
639
360
|
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
### Low-level API
|
|
643
|
-
|
|
644
|
-
These utilities power the framework and are exported for extension and testing.
|
|
361
|
+
`repeat` matches items by `key` with a two-pointer walk, reusing, moving, creating, or removing as few nodes as possible, and uses `moveBefore` when available to preserve element state across moves. When nothing changed — same keys, same order, equal items — it bails out before touching the DOM at all. Use `list` instead when the array never changes shape.
|
|
645
362
|
|
|
646
|
-
|
|
363
|
+
### Boundaries and teardown
|
|
647
364
|
|
|
648
|
-
|
|
649
|
-
interface Boundary {
|
|
650
|
-
start: Node;
|
|
651
|
-
end: Node;
|
|
652
|
-
}
|
|
653
|
-
```
|
|
365
|
+
A `View` occupies a contiguous **range of sibling nodes**, described by `boundary(): {start, end}`. This is how Sigula swaps or removes multi-node regions without a wrapper element or a virtual tree.
|
|
654
366
|
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
#### `toBoundary`
|
|
367
|
+
Teardown is explicit and recursive:
|
|
658
368
|
|
|
659
369
|
```ts
|
|
660
|
-
const
|
|
370
|
+
const dispose = render(App(), app);
|
|
371
|
+
dispose(); // removeBoundary(view.boundary()) + view.cleanBinds()
|
|
661
372
|
```
|
|
662
373
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
#### `walkBoundary`
|
|
666
|
-
|
|
667
|
-
```ts
|
|
668
|
-
const walkBoundary: (b: Boundary, fn: (node: Node) => void) => void;
|
|
669
|
-
```
|
|
374
|
+
`cleanBinds()` detaches the view's own bind and, recursively, all child binds. When a `Sig` loses its last bind it calls `cleanup()`, so a subtree that is removed stops receiving updates immediately — no manual effect cleanup, no leak by default.
|
|
670
375
|
|
|
671
|
-
|
|
376
|
+
### The update queue
|
|
672
377
|
|
|
673
|
-
|
|
378
|
+
Writes never run synchronously. Every write pushes the signal's binds onto one global queue and schedules a single `queueMicrotask`.
|
|
674
379
|
|
|
675
380
|
```ts
|
|
676
|
-
|
|
677
|
-
|
|
381
|
+
sig0.update(a); // ┐
|
|
382
|
+
sig1.update(b); // ├── one microtask
|
|
383
|
+
sig2.update(c); // ┘
|
|
678
384
|
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
#### `replaceWithNode`
|
|
682
|
-
|
|
683
|
-
```ts
|
|
684
|
-
const replaceWithNode: (old: Boundary, node: Node) => Boundary;
|
|
385
|
+
sig.update(1); sig.update(2); sig.update(3); // each dependent binding runs ONCE, against 3
|
|
685
386
|
```
|
|
686
387
|
|
|
687
|
-
|
|
388
|
+
- **Batched** across signals — many writes, one flush.
|
|
389
|
+
- **Coalesced per binding** — a `queued` flag keeps a bind from entering the queue twice; since `cmd` reads `sig.get()` at call time, it always sees the newest value.
|
|
390
|
+
- **Error-isolated** — a throwing bind is logged (`console.error('[Queue] task failed:', …)`) and the rest of the queue still runs.
|
|
688
391
|
|
|
689
|
-
|
|
392
|
+
### What Sigula deliberately does not have
|
|
690
393
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
394
|
+
| Not included | Why |
|
|
395
|
+
| --- | --- |
|
|
396
|
+
| Virtual DOM / diffing | Updates are bound at mount time; there is nothing to diff. |
|
|
397
|
+
| A component instance or lifecycle | A component is a function that returns a `View`, and it runs once. |
|
|
398
|
+
| A compiler / build step | Templates are native tagged-template strings. |
|
|
399
|
+
| A router, store, or SSR runtime | Out of scope. Sigula is the rendering and reactivity layer; bring your own. |
|
|
400
|
+
| Proxy-based deep reactivity | Values are compared, not wrapped. Mutate in place and call `notify()`. |
|
|
401
|
+
| Automatic dependency tracking | Bindings are explicit (`sig` → `cmd` → target), which is what keeps the runtime at ~4.5KB. |
|
|
699
402
|
|
|
700
|
-
|
|
403
|
+
## 📖 API Cheat Sheet
|
|
701
404
|
|
|
702
|
-
|
|
405
|
+
Full signatures and documentation: [Reference.md](./Reference.md).
|
|
703
406
|
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
407
|
+
| Export | Kind | Returns |
|
|
408
|
+
| --- | --- | --- |
|
|
409
|
+
| `sig(v, opts?)` | state | `Sig<T>` |
|
|
410
|
+
| `compute(sig, fn)` / `compute(record, fn)` | derived | `DerivedSig<T>` |
|
|
411
|
+
| `html\`…\`` | template | `View` |
|
|
412
|
+
| `text(source)` | template | `View` (escaped text node) |
|
|
413
|
+
| `raw(source)` | template | `View` (**unescaped** HTML) |
|
|
414
|
+
| `patch(props \| …items)` | binding | `Patch` |
|
|
415
|
+
| `id`, `val`, `attr`, `style`, `styleProp`, `toggleClass`, `toggleClasses`, `on`, `act` | patch commands | `ToPatchItem<T>` |
|
|
416
|
+
| `view(sig, viewFn)` | control flow | `View` |
|
|
417
|
+
| `repeat(sig, {key, view, eq?})` | control flow | `View` |
|
|
418
|
+
| `list(items, viewFn)` | control flow | `View` (static) |
|
|
419
|
+
| `frag(...views)` | composition | `View` |
|
|
420
|
+
| `render(view \| () => view, node)` | mounting | disposer `() => void` |
|
|
421
|
+
| `createBind`, `removeBind`, `eq`, `toBoundary`, `walkBoundary` | low-level | — |
|
|
422
|
+
| `Sig`, `DerivedSig`, `View`, `Patch`, `Reactive<T>`, `Eq<T>`, `Equatable` | types | — |
|
|
423
|
+
|
|
424
|
+
The full API reference is generated from the TSDoc comments in the source: see [Reference.md](./Reference.md).
|
|
425
|
+
|
|
426
|
+
## Errors
|
|
427
|
+
|
|
428
|
+
Runtime errors carry a short code in `message` instead of a sentence, so the string tables stay out of the bundle. Codes with arguments are colon-separated.
|
|
707
429
|
|
|
708
430
|
| Code | Thrown by | Meaning |
|
|
709
431
|
| --- | --- | --- |
|
|
710
432
|
| `E1:<index>` | `at` | Array index out of range. |
|
|
711
433
|
| `E2` | `toBoundary` | Cannot build a boundary from an empty fragment. |
|
|
712
434
|
| `E3` | `replaceWithNode` | The old boundary has no `parentNode`. |
|
|
713
|
-
| `E4` | `patch` | A keyed command (`attr`, `style`, `
|
|
435
|
+
| `E4` | `patch` | A keyed command (`attr`, `style`, `styleProp`, `toggleClass`) was given no key. |
|
|
714
436
|
| `E5` | `patch` | `act` was given no function. |
|
|
715
437
|
| `E6` | `patch` | `on` was given no event type. |
|
|
716
438
|
| `E7` | `repeat` | The rendered items have no parent node. |
|
|
@@ -718,15 +440,20 @@ Look yours up here:
|
|
|
718
440
|
| `E9` | `repeat` | There is no node after the fence to move before. |
|
|
719
441
|
| `E10` | `html` | The template is empty (an empty tagged template). |
|
|
720
442
|
| `E11:<expected>:<got>` | `html` | Interpolation count does not match the template's slots. |
|
|
721
|
-
| `E12` | `html` | Unmatched interpolation
|
|
443
|
+
| `E12` | `html` | Unmatched interpolation: a `Patch` must sit in an attribute position, a `View`/text value in a content position. |
|
|
444
|
+
|
|
445
|
+
## How it compares
|
|
722
446
|
|
|
723
|
-
|
|
447
|
+
| | Sigula | Lit | Solid | React |
|
|
448
|
+
| --- | --- | --- | --- | --- |
|
|
449
|
+
| Update model | Signal → bind → DOM node | Property → `render()` → part commit | Signal → compiled DOM | Component re-render → VDOM diff |
|
|
450
|
+
| Compiler required | No | No | Yes (JSX/babel) | Yes (JSX) |
|
|
451
|
+
| Component re-runs | Never | On property change | Never | On every state change |
|
|
452
|
+
| Approx. size | ~4.5KB min+gzip | ~5KB | ~7KB | — (much larger runtime) |
|
|
453
|
+
| Templating | Native tagged templates | Tagged templates | JSX | JSX |
|
|
454
|
+
| Standard Web Components | No (any DOM node) | Yes | No | No |
|
|
724
455
|
|
|
725
|
-
|
|
726
|
-
- **Coalesced per binding.** A binding that is written to multiple times before the microtask flush runs once, reading the signal's final value. `sig.update(1); sig.update(2); sig.update(3)` runs each dependent binding a single time against `3`.
|
|
727
|
-
- **`update` vs `forceUpdate`.** `update` skips work when the new value is deeply equal to the current one; `forceUpdate` always notifies. Use `forceUpdate` when a value is structurally equal but you still need a re-render (for example, mutating an object in place).
|
|
728
|
-
- **Error isolation.** A throwing binding does not stop the rest of the queue; the error is logged as `console.error('[Queue] task failed:', error, bind)`.
|
|
729
|
-
- **Deep equality by default.** `update`, `compute`, and `repeat` compare with `isEqual`, so replacing `{a: 1}` with another `{a: 1}` is a no-op.
|
|
456
|
+
*Size figures are each project's own published claim, measured with different tooling and feature sets — treat them as an order of magnitude, not a benchmark.*
|
|
730
457
|
|
|
731
458
|
## License
|
|
732
459
|
|