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 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 state changes, only the DOM nodes that actually depend on it are updated, not the whole component tree
10
- - Declarative signal sources + precise bindings — describe data sources declaratively with signals, then bind them precisely to the DOM or any effect
11
- - No virtual DOM — no diffing, no VNodes. Direct real DOM operations with minimal runtime overhead
12
- - Minimal HTML templates — based on native string templates. No custom compiler, no DSL — just JavaScript strings
13
- - Batched, coalesced updates — writes are queued in a microtask, so a signal touched many times before the flush runs its bindings once, with the final value
14
- - Ultra small — ~4.0KB minified + gzipped
15
- - TypeScript friendly — full type inference for signals and template bindings
16
- - Simple but performant — tiny API surface, low mental overhead, no compromise on performance
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
- ## Quick Start
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
- return html`<div>
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
- Traditional frameworks like React re-run component functions, generate a virtual DOM, diff it, and finally apply changes to the real DOM. Sigula works completely differently:
42
+ ### 1. Render something
83
43
 
84
44
  ```ts
85
- import {html, text} from 'sigula';
45
+ import {html, render} from 'sigula';
86
46
 
87
- const name = sig('Alice');
88
-
89
- const App = html`<h1>Hello, ${text(name)}!</h1>`;
47
+ const app = document.querySelector('#app')!;
90
48
 
91
- // When name changes, only the text node inside <h1> is updated
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
- Only the exact text node that depends on `name` is updated. Everything else stays untouched.
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
- Signals are the single source of truth. Every view and side effect is derived from them:
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
- todos.trans((items) => [
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
- const itemView = (item: Todo) => html`<li>
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
- const appNode = document.querySelector('#app');
201
- if (!appNode) throw new Error('#app not found');
202
- render(Todos(), appNode);
63
+ // Only the text node inside <h1> is updated.
64
+ name.update('Bob'); // → "Hello, Bob!"
203
65
  ```
204
66
 
205
- Signals can bind to the DOM, to effects, or to other computed signals — one reactive model across the entire application.
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
- This means:
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
- ### Minimal HTML templates
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
- const App: View = html`
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.get(); // 0
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
- | Member | Signature | Description |
270
- | --- | --- | --- |
271
- | `get` | `(): T` | Reads the current value. |
272
- | `update` | `(v: T): void` | Sets the value and notifies dependents, but only if `isEqual(v, current)` is `false`. |
273
- | `forceUpdate` | `(v: T): void` | Sets the value and always notifies dependents, even when deeply equal. |
274
- | `trans` | `(fn: (v: T) => T): void` | Applies `fn` to the current value via `update`, so an equal result is skipped. |
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
- Derives a signal from one source signal, or from a record of signals (whose values are passed as a matching record). The result is recomputed whenever any source changes.
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
- Supporting types:
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
- interface SigRecord {
315
- [key: string]: Sig<any>;
316
- }
92
+ import {html, on, patch, render, sig, type View} from 'sigula';
317
93
 
318
- type ValRecord<K extends SigRecord> = {
319
- [P in keyof K]: K[P] extends Sig<infer U> ? U : never;
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
- `ValRecord` maps a record of signals to the record of their values, which is what `compute`'s record overload passes to `fn`.
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
- #### `isEqual`
107
+ ### 5. Render lists conditionally
326
108
 
327
109
  ```ts
328
- const isEqual: <T>(a: T, b: T) => boolean;
329
- ```
110
+ import {compute, html, on, patch, render, repeat, sig, text, val, view} from 'sigula';
330
111
 
331
- Deep structural equality. Compares primitives, arrays, `Date`, `RegExp`, `Map`, `Set`, and plain objects, and defers to `a.equals(b)` when `a` implements `Equatable`. This is the default comparator for `Sig.update` and `repeat`. Two objects with different prototypes are never equal, so instances of different classes and objects from different realms (iframes, workers) always compare unequal.
112
+ interface Todo { id: number; text: string; done: boolean }
332
113
 
333
- #### `Equatable`
114
+ const Todos = () => {
115
+ const input = sig('');
116
+ const todos = sig<Todo[]>([]);
117
+ const filter = sig<'all' | 'active'>('all');
334
118
 
335
- ```ts
336
- interface Equatable {
337
- equals(other: unknown): boolean;
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
- Implement this on a value type to give `isEqual` custom semantics.
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
- #### `UnknownRecord`
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
- ```ts
346
- type UnknownRecord = Record<string, unknown>;
145
+ const dispose = render(Todos(), app);
146
+ dispose(); // detaches every binding and removes the nodes
347
147
  ```
348
148
 
349
- Convenience alias for an arbitrary string-keyed object, used by the equality and signal-record helpers.
149
+ Runnable versions live in [`examples/`](./examples) (`equation`, `filtertodos`).
350
150
 
351
- #### `createBind` / `removeBind`
151
+ ## 🧠 Core Concepts
352
152
 
353
- ```ts
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
- Low-level bind management. `createBind` wires `cmd(sig.get(), context)` to run whenever `sig` changes; `removeBind` detaches it. `Bind` is the resulting record:
364
-
365
- ```ts
366
- interface Bind<T, C extends CmdContext> {
367
- sig: Sig<T>;
368
- context: C;
369
- cmd: Cmd<T, C>;
370
- removed: boolean;
371
- queued?: boolean;
372
- }
373
-
374
- type AnyBind = Bind<any, any>;
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
- ### Templates
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
- #### `html`
173
+ ### Signals: `sig`
380
174
 
381
- ```ts
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
- html`<p>${text(label)}</p>`;
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
- ```ts
403
- const text: <T>(source: T | Sig<T>) => View<T, PatchContext>;
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
- Creates a text-node view. With a `Sig`, the text updates whenever the signal changes; with a plain value it is static.
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
- ```ts
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
- #### `View<T, C>` / `AnyView`
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
- interface View<T = unknown, C extends CmdContext = any> {
416
- type: 'view';
417
- node: Node;
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
- `View` is the unit returned by `html`, `text`, `view`, and `repeat`. Its `node` is a DOM node or `DocumentFragment`. `boundary` returns the nodes the view currently occupies; `render` calls it at disposal time so a view that swaps its own contents (`view`, `repeat`) is torn down from its current nodes. `cleanBinds` detaches the view's bindings and, recursively, those of its `children`.
207
+ ### Bindings: the unit of reactivity
428
208
 
429
- #### `replaceWithView`
209
+ A binding is a three-field record — that is the entire reactive primitive:
430
210
 
431
211
  ```ts
432
- const replaceWithView: (old: Boundary, view: View) => Boundary;
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
- Helper used by `view` and `repeat` to swap a mounted view: replaces an existing boundary with the view's node and returns the new boundary.
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 patch: (...toPatchItems: ToAnyPatchItem[]) => Patch;
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
- ```ts
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
- #### `id`
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
- Sets the element's `id`.
232
+ ### Derived signals: `compute`
458
233
 
459
- #### `val`
234
+ `compute` returns a `DerivedSig<T>`, which is a `Sig` you cannot write to. Two overloads:
460
235
 
461
236
  ```ts
462
- const val: <T>(source: T | Sig<T>) => ToPatchItem<T>;
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
- ```ts
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
- Sets attribute `key`. Use this for boolean/ARIA/data attributes.
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
- ```ts
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
- Sets an inline style property by typed name.
247
+ ### Templates: `html`
485
248
 
486
249
  ```ts
487
- html`<span ${patch(style(color, 'color'))}>text</span>`;
250
+ const html = (strs: TemplateStringsArray, ...rawItems: HtmlItem[]): View;
488
251
  ```
489
252
 
490
- `WritableStyleKey` is the union of `CSSStyleDeclaration` keys whose values are strings.
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
- ```ts
495
- const styleProperty: <T>(source: T | Sig<T>, key: string) => ToPatchItem<T>;
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
- Sets a style property via `CSSStyleDeclaration.setProperty`. Use this for custom properties (`--my-var`) or untyped names.
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
- html`<div ${patch(styleProperty(size, '--size'))}></div>`;
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
- #### `toggleClass`
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
- ```ts
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
- Toggles a single class from the truthiness of the value.
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
- ```ts
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
- Toggles several classes from one value.
282
+ Every call site gets a random marker, `@sig_<rand>`. Interpolations are rendered into the template string as:
522
283
 
523
- #### `act`
284
+ - an **attribute marker** `@sig_x` for a `Patch`,
285
+ - a **comment marker** `<!--@sig_x-->` for a `View`.
524
286
 
525
- ```ts
526
- type ActFn<T> = (node: Node, val?: T) => void;
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
- Runs arbitrary code with the bound node and value; runs on mount and again on change. Use it as the escape hatch for anything the built-in commands do not cover.
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
- ```ts
533
- html`<canvas ${patch(act(frame, (node, v) => draw(node, v)))}></canvas>`;
534
- ```
535
-
536
- #### `on`
293
+ Two consequences worth knowing:
537
294
 
538
- ```ts
539
- const on: <K extends keyof HTMLElementEventMap>(
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
- Adds a DOM event listener. The listener is registered once at mount; it is not a reactive source, so combine it with `sig` writes to drive updates.
298
+ The returned `View` is `{node, children, boundary(), cleanBinds()}` — see [Boundaries](#boundaries-and-teardown).
549
299
 
550
- ```ts
551
- html`<button ${patch(on('click', () => count.trans((v) => v + 1)))}>+1</button>`;
552
- ```
300
+ ### Patching an element: `patch`
553
301
 
554
- #### Patch types
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
- interface PatchContext extends CmdContext {
558
- node: Node;
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
- ```ts
586
- const view: <T>(
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
- Conditionally renders one view or another. Whenever `sig` changes, `viewFn` is called with the new value, the previous view is torn down, and a new one is mounted in its place.
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
- ```ts
595
- html`<div>${view(isEmpty, (v) => (v ? text('empty') : list))}</div>`;
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
- #### `repeat`
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
- type RepeatProp<T> = {
602
- key: (item: T) => string;
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
- const repeat: <T>(sig: Sig<T[]>, prop: RepeatProp<T>) => View<T[]>;
332
+ html`<button ${patch(attr('disabled', disabled), attr('aria-label', label))}>Go</button>`
333
+ // ↑ reactive ↑ static
608
334
  ```
609
335
 
610
- Keyed list rendering. On each change Sigula matches items by `key`, then reuses, moves, creates, or removes as few DOM nodes as possible. `compare` defaults to `isEqual`; when an item is deeply equal to the track it already occupies, the track is reused without rebuilding its view. An empty array renders `<!--empty-list-->`.
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
- `key` must be unique and stable for a given item. `compare` is useful when item identity is structural but you want to force or skip updates.
338
+ ### Control flow
620
339
 
621
- ### Rendering
340
+ Sigula has exactly four control-flow helpers, all returning a `View`:
622
341
 
623
- #### `render`
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
- const render: (
627
- viewArg: AnyView | (() => AnyView),
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
- ```ts
635
- const dispose = render(App(), document.querySelector('#app')!);
636
- render(() => html`<p>lazy</p>`, document.body);
637
- dispose();
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
- A view that swaps its own contents — one built with `view()` or `repeat()` at the root — disposes the nodes currently in `node`, not the ones originally appended. An empty tagged template throws `E10`.
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
- #### `Boundary`
363
+ ### Boundaries and teardown
647
364
 
648
- ```ts
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
- An inclusive range of sibling nodes (`start` through `end`).
656
-
657
- #### `toBoundary`
367
+ Teardown is explicit and recursive:
658
368
 
659
369
  ```ts
660
- const toBoundary: (node: Node) => Boundary;
370
+ const dispose = render(App(), app);
371
+ dispose(); // removeBoundary(view.boundary()) + view.cleanBinds()
661
372
  ```
662
373
 
663
- Wraps a node in a `Boundary`. For a `DocumentFragment`, the boundary spans its first and last child; otherwise it covers the node itself. Throws `E2` on an empty fragment.
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
- Visits every node from `b.start` through `b.end`. Callers capture the next sibling before mutating; `removeBoundary` and `repeat`'s reordering are built on it.
376
+ ### The update queue
672
377
 
673
- #### `removeBoundary`
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
- const removeBoundary: (b: Boundary) => void;
677
- ```
381
+ sig0.update(a); // ┐
382
+ sig1.update(b); // ├── one microtask
383
+ sig2.update(c); // ┘
678
384
 
679
- Removes every node in the boundary. A no-op if the boundary has no parent.
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
- Replaces an entire boundary with `node` and returns the new boundary. Throws `E3` if `old` has no parent. This is the primitive behind dynamic `view` and `repeat` swaps.
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
- #### `Cmd` / `AnyCmd` / `CmdContext`
392
+ ### What Sigula deliberately does not have
690
393
 
691
- ```ts
692
- interface CmdContext {
693
- [key: string]: unknown;
694
- }
695
-
696
- type Cmd<T, C extends CmdContext> = (val: T, context: C) => void;
697
- type AnyCmd = Cmd<any, any>;
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
- A `Cmd` is the unit of work a binding runs: it receives the current signal value and its context.
403
+ ## 📖 API Cheat Sheet
701
404
 
702
- ### Errors
405
+ Full signatures and documentation: [Reference.md](./Reference.md).
703
406
 
704
- Runtime errors carry a short code in `message` instead of a sentence, so the
705
- string tables stay out of the bundle. Codes with arguments are colon-separated.
706
- Look yours up here:
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`, `styleProperty`, `toggleClass`) was given no key. |
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; `patch()` must be in attribute position. |
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
- ### Reactivity model
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
- - **Batched.** When a signal changes, its bindings are queued, not run synchronously.
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