sigula 1.0.3 → 2.0.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
@@ -11,7 +11,7 @@ A minimal, signal-based web framework with fine-grained reactivity. No virtual D
11
11
  - No virtual DOM — no diffing, no VNodes. Direct real DOM operations with minimal runtime overhead
12
12
  - Minimal HTML templates — based on native string templates. No custom compiler, no DSL — just JavaScript strings
13
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.1KB minified + gzipped
14
+ - Ultra small — ~4.0KB minified + gzipped
15
15
  - TypeScript friendly — full type inference for signals and template bindings
16
16
  - Simple but performant — tiny API surface, low mental overhead, no compromise on performance
17
17
 
@@ -160,8 +160,8 @@ const Todos = (): View => {
160
160
  ${patch(
161
161
  on('click', () => item.done.trans((v) => !v)),
162
162
  style(
163
- compute(item.done, (v): string => (v ? 'line-through' : 'none')),
164
163
  'textDecoration',
164
+ compute(item.done, (v): string => (v ? 'line-through' : 'none')),
165
165
  ),
166
166
  )}
167
167
  >${text(item.text)}</span>
@@ -231,484 +231,41 @@ const App: View = html`
231
231
 
232
232
  ### Ultra small
233
233
 
234
- minified + gzipped: ~4.1KB
234
+ minified + gzipped: ~4.0KB
235
235
 
236
236
  ## 📖 Reference
237
237
 
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
- - [Reactivity model](#reactivity-model)
247
-
248
- ### Reactivity
249
-
250
- #### `sig`
251
-
252
- ```ts
253
- const sig: <T>(v: T) => Sig<T>;
254
- ```
255
-
256
- Creates a writable signal holding `v`.
257
-
258
- ```ts
259
- const count = sig(0);
260
- count.get(); // 0
261
- count.update(1); // schedules dependents
262
- ```
263
-
264
- #### `Sig<T>`
265
-
266
- The core reactive value.
267
-
268
- | Member | Signature | Description |
269
- | --- | --- | --- |
270
- | `get` | `(): T` | Reads the current value. |
271
- | `update` | `(v: T): void` | Sets the value and notifies dependents, but only if `isEqual(v, current)` is `false`. |
272
- | `forceUpdate` | `(v: T): void` | Sets the value and always notifies dependents, even when deeply equal. |
273
- | `trans` | `(fn: (v: T) => T): void` | Applies `fn` to the current value via `update`, so an equal result is skipped. |
274
- | `equals` | `(other: unknown): boolean` | `Equatable` implementation; two `Sig`s are equal when their values are deeply equal. |
275
- | `addBind` | `<C>(bind: Bind<T, C>): void` | Registers a binding. Prefer `createBind` / the `patch`/`text`/`view` APIs. |
276
- | `removeBind` | `(bind: Bind<T, CmdContext>): void` | Unregisters a binding; runs `cleanup()` when the last one goes away. |
277
- | `getBinds` | `(): Bind<T, CmdContext>[]` | Returns the current bindings. |
278
- | `cleanup` | `(): void` | Overridable hook called when a signal loses all bindings. No-op on `Sig`. |
238
+ The full API reference is generated from the TSDoc comments in the source: see [Reference.md](./Reference.md).
279
239
 
280
- #### `DerivedSig<T>`
240
+ ## Errors
281
241
 
282
- 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.
242
+ Runtime errors carry a short code in `message` instead of a sentence, so the
243
+ string tables stay out of the bundle. Codes with arguments are colon-separated.
244
+ Look yours up here:
283
245
 
284
- | Member | Signature | Description |
246
+ | Code | Thrown by | Meaning |
285
247
  | --- | --- | --- |
286
- | `addFromBind` | `<S, C>(bind: Bind<S, C>): void` | Registers a source binding. |
287
- | `addBind` | `<C>(bind: Bind<T, C>): void` | Registers a consumer; re-links to sources and recomputes once if the derived signal was detached. |
288
- | `cleanup` | `(): void` | Removes every source binding when the derived signal has no consumers. |
289
-
290
- #### `compute`
291
-
292
- ```ts
293
- function compute<S, T>(source: Sig<S>, fn: (v: S) => T): DerivedSig<T>;
294
- function compute<S extends SigRecord, T>(
295
- source: S,
296
- fn: (v: ValRecord<S>) => T,
297
- ): DerivedSig<T>;
298
- ```
299
-
300
- 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.
301
-
302
- ```ts
303
- const x = sig(1);
304
- const y = sig(2);
305
-
306
- const sum = compute({x, y}, (v) => v.x + v.y); // DerivedSig<number>
307
- const doubled = compute(x, (v) => v * 2); // DerivedSig<number>
308
- ```
309
-
310
- Supporting types:
311
-
312
- ```ts
313
- interface SigRecord {
314
- [key: string]: Sig<any>;
315
- }
316
-
317
- type ValRecord<K extends SigRecord> = {
318
- [P in keyof K]: K[P] extends Sig<infer U> ? U : never;
319
- };
320
- ```
321
-
322
- `ValRecord` maps a record of signals to the record of their values, which is what `compute`'s record overload passes to `fn`.
323
-
324
- #### `isEqual`
325
-
326
- ```ts
327
- const isEqual: <T>(a: T, b: T) => boolean;
328
- ```
329
-
330
- 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.
331
-
332
- #### `Equatable`
333
-
334
- ```ts
335
- interface Equatable {
336
- equals(other: unknown): boolean;
337
- }
338
- ```
339
-
340
- Implement this on a value type to give `isEqual` custom semantics.
341
-
342
- #### `UnknownRecord`
343
-
344
- ```ts
345
- type UnknownRecord = Record<string, unknown>;
346
- ```
347
-
348
- Convenience alias for an arbitrary string-keyed object, used by the equality and signal-record helpers.
349
-
350
- #### `createBind` / `removeBind`
351
-
352
- ```ts
353
- const createBind: <T, C extends CmdContext>(
354
- sig: Sig<T>,
355
- context: C,
356
- cmd: Cmd<T, C>,
357
- ) => Bind<T, C>;
358
-
359
- const removeBind: (bind: Bind<unknown, CmdContext>) => void;
360
- ```
361
-
362
- Low-level bind management. `createBind` wires `cmd(sig.get(), context)` to run whenever `sig` changes; `removeBind` detaches it. `Bind` is the resulting record:
363
-
364
- ```ts
365
- interface Bind<T, C extends CmdContext> {
366
- sig: Sig<T>;
367
- context: C;
368
- cmd: Cmd<T, C>;
369
- removed: boolean;
370
- queued?: boolean;
371
- }
372
-
373
- type AnyBind = Bind<any, any>;
374
- ```
375
-
376
- ### Templates
377
-
378
- #### `html`
379
-
380
- ```ts
381
- const html: (
382
- strs: TemplateStringsArray,
383
- ...items: (Patch | AnyView)[]
384
- ) => View;
385
- ```
386
-
387
- Tagged template that parses native HTML and returns a `View`. Two kinds of interpolation are supported:
388
-
389
- - a `View` (from `text`, `view`, `repeat`, or another `html`) fills a content position
390
- - a `Patch` (from `patch(...)`) fills an attribute position
391
-
392
- ```ts
393
- html`<p>${text(label)}</p>`;
394
- html`<button ${patch(on('click', handler))}>Go</button>`;
395
- ```
396
-
397
- Templates are cached per call site, so repeated renders skip parsing. Using `patch(...)` in a content position throws `html: unmatched interpolation; patch() must be in attribute position`. An interpolation count that does not match the number of slots throws `html: expected N interpolation(s), got M`; a mismatch means the cached template for that call site was built from a different mix of interpolations.
398
-
399
- #### `text`
400
-
401
- ```ts
402
- const text: <T>(source: T | Sig<T>) => View<T, PatchContext>;
403
- ```
404
-
405
- Creates a text-node view. With a `Sig`, the text updates whenever the signal changes; with a plain value it is static.
406
-
407
- ```ts
408
- html`<span>${text(count)}</span>`;
409
- ```
410
-
411
- #### `View<T, C>` / `AnyView`
412
-
413
- ```ts
414
- interface View<T = unknown, C extends CmdContext = any> {
415
- type: 'view';
416
- node: Node;
417
- bind?: Bind<T, C> | undefined;
418
- childCommits?: Commit<unknown, CmdContext>[];
419
- live?: () => Boundary | undefined;
420
- }
421
-
422
- type AnyView = View<any, any>;
423
- ```
424
-
425
- `View` is the unit returned by `html`, `text`, `view`, and `repeat`. Its `node` is a DOM node or `DocumentFragment`. `live` returns the boundary 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.
426
-
427
- #### `extractBoundary` / `replaceWithView`
428
-
429
- ```ts
430
- const extractBoundary: (view: View) => Boundary;
431
- const replaceWithView: (old: Boundary, view: View) => Boundary;
432
- ```
433
-
434
- Helpers used by `view` and `repeat` to mount a view and later swap it. `extractBoundary` wraps `view.node` in a `Boundary`; `replaceWithView` replaces an existing boundary with the view's node and returns the new boundary.
435
-
436
- ### DOM bindings
437
-
438
- #### `patch`
439
-
440
- ```ts
441
- const patch: (...toPatchItems: ToAnyPatchItem[]) => Patch;
442
- ```
443
-
444
- 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).
445
-
446
- ```ts
447
- html`<input ${patch(val(name), attr(placeholder, 'name'))} />`;
448
- ```
449
-
450
- #### `id`
451
-
452
- ```ts
453
- const id: <T>(source: T | Sig<T>) => ToPatchItem<T>;
454
- ```
455
-
456
- Sets the element's `id`.
457
-
458
- #### `val`
459
-
460
- ```ts
461
- const val: <T>(source: T | Sig<T>) => ToPatchItem<T>;
462
- ```
463
-
464
- Sets the element's `value` property (form controls).
465
-
466
- #### `attr`
467
-
468
- ```ts
469
- const attr: <T>(source: T | Sig<T>, key: string) => ToPatchItem<T>;
470
- ```
471
-
472
- Sets attribute `key`. Use this for boolean/ARIA/data attributes.
473
-
474
- #### `style`
475
-
476
- ```ts
477
- const style: <T>(
478
- source: T | Sig<T>,
479
- key: WritableStyleKey,
480
- ) => ToPatchItem<T>;
481
- ```
482
-
483
- Sets an inline style property by typed name.
484
-
485
- ```ts
486
- html`<span ${patch(style(color, 'color'))}>text</span>`;
487
- ```
488
-
489
- `WritableStyleKey` is the union of `CSSStyleDeclaration` keys whose values are strings.
490
-
491
- #### `styleProperty`
492
-
493
- ```ts
494
- const styleProperty: <T>(source: T | Sig<T>, key: string) => ToPatchItem<T>;
495
- ```
496
-
497
- Sets a style property via `CSSStyleDeclaration.setProperty`. Use this for custom properties (`--my-var`) or untyped names.
498
-
499
- ```ts
500
- html`<div ${patch(styleProperty(size, '--size'))}></div>`;
501
- ```
502
-
503
- #### `toggleClass`
504
-
505
- ```ts
506
- const toggleClass: <T>(source: T | Sig<T>, token: string) => ToPatchItem<T>;
507
- ```
508
-
509
- Toggles a single class from the truthiness of the value.
510
-
511
- #### `toggleClasses`
512
-
513
- ```ts
514
- const toggleClasses: <T>(
515
- source: T | Sig<T>,
516
- ...tokens: string[]
517
- ) => ToPatchItem<T>;
518
- ```
519
-
520
- Toggles several classes from one value.
521
-
522
- #### `act`
523
-
524
- ```ts
525
- type ActFn<T> = (node: Node, val?: T) => void;
526
- const act: <T>(source: T | Sig<T>, fn: ActFn<T>) => ToPatchItem<T>;
527
- ```
528
-
529
- 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.
530
-
531
- ```ts
532
- html`<canvas ${patch(act(frame, (node, v) => draw(node, v)))}></canvas>`;
533
- ```
534
-
535
- #### `on`
536
-
537
- ```ts
538
- const on: <K extends keyof HTMLElementEventMap>(
539
- type: K,
540
- listener: (this: HTMLElement, ev: HTMLElementEventMap[K]) => unknown,
541
- options?: boolean | AddEventListenerOptions,
542
- ) => ToPatchItem<
543
- (this: HTMLElement, ev: HTMLElementEventMap[K]) => unknown
544
- >;
545
- ```
546
-
547
- 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.
548
-
549
- ```ts
550
- html`<button ${patch(on('click', () => count.trans((v) => v + 1)))}>+1</button>`;
551
- ```
552
-
553
- #### Patch types
554
-
555
- ```ts
556
- interface PatchContext extends CmdContext {
557
- node: Node;
558
- extra?: unknown[];
559
- }
560
-
561
- interface PatchItem<T> {
562
- source: T | Sig<T>;
563
- context: PatchContext;
564
- cmd: Cmd<T, PatchContext>;
565
- }
566
-
567
- type ToPatchItem<T> = (el: Element) => PatchItem<T>;
568
- type AnyPatchItem = PatchItem<any>;
569
- type ToAnyPatchItem = (el: Element) => AnyPatchItem;
570
-
571
- interface Patch {
572
- type: 'patch';
573
- toPatchItems: ToAnyPatchItem[];
574
- }
575
- ```
576
-
577
- `ToPatchItem` defers reading the target element until mount. `patch` collects these factories into a single `Patch`.
578
-
579
- ### Control flow
580
-
581
- #### `view`
582
-
583
- ```ts
584
- const view: <T>(
585
- sig: Sig<T>,
586
- viewFn: (val: T) => AnyView,
587
- ) => View<T>;
588
- ```
589
-
590
- 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.
591
-
592
- ```ts
593
- html`<div>${view(isEmpty, (v) => (v ? text('empty') : list))}</div>`;
594
- ```
595
-
596
- #### `repeat`
597
-
598
- ```ts
599
- type RepeatProp<T> = {
600
- key: (item: T) => string;
601
- view: (item: T) => AnyView;
602
- compare?: (a: T, b: T) => boolean;
603
- };
604
-
605
- const repeat: <T>(sig: Sig<T[]>, prop: RepeatProp<T>) => View<T[]>;
606
- ```
607
-
608
- 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-->`.
609
-
610
- ```ts
611
- html`<ul>${repeat(todos, {
612
- key: (item) => item.id.toString(),
613
- view: (item) => html`<li>${text(item.label)}</li>`,
614
- })}</ul>`;
615
- ```
616
-
617
- `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.
618
-
619
- ### Rendering
620
-
621
- #### `render`
622
-
623
- ```ts
624
- const render: (
625
- viewArg: AnyView | (() => AnyView),
626
- node: Node,
627
- ) => () => void;
628
- ```
629
-
630
- 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.
631
-
632
- ```ts
633
- const dispose = render(App(), document.querySelector('#app')!);
634
- render(() => html`<p>lazy</p>`, document.body);
635
- dispose();
636
- ```
637
-
638
- 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 template such as html`` renders nothing and its disposer is a no-op.
639
-
640
- ### Low-level API
641
-
642
- These utilities power the framework and are exported for extension and testing.
643
-
644
- #### `Boundary`
645
-
646
- ```ts
647
- interface Boundary {
648
- start: Node;
649
- end: Node;
650
- }
651
- ```
652
-
653
- An inclusive range of sibling nodes (`start` through `end`).
654
-
655
- #### `toBoundary`
656
-
657
- ```ts
658
- const toBoundary: (node: Node) => Boundary;
659
- ```
660
-
661
- Wraps a node in a `Boundary`. For a `DocumentFragment`, the boundary spans its first and last child; otherwise it covers the node itself. Throws `toBoundary: empty fragment` on an empty fragment.
662
-
663
- #### `removeBoundary`
664
-
665
- ```ts
666
- const removeBoundary: (b: Boundary) => void;
667
- ```
668
-
669
- Removes every node in the boundary. A no-op if the boundary has no parent.
670
-
671
- #### `replaceWithNode`
672
-
673
- ```ts
674
- const replaceWithNode: (old: Boundary, node: Node) => Boundary;
675
- ```
676
-
677
- Replaces an entire boundary with `node` and returns the new boundary. Throws if `old` has no parent. This is the primitive behind dynamic `view` and `repeat` swaps.
678
-
679
- #### `Cmd` / `AnyCmd` / `CmdContext`
680
-
681
- ```ts
682
- interface CmdContext {
683
- [key: string]: unknown;
684
- }
685
-
686
- type Cmd<T, C extends CmdContext> = (val: T, context: C) => void;
687
- type AnyCmd = Cmd<any, any>;
688
- ```
689
-
690
- A `Cmd` is the unit of work a binding runs: it receives the current signal value and its context.
691
-
692
- #### `Commit` / `cleanCommit`
693
-
694
- ```ts
695
- interface Commit<T, C extends CmdContext> {
696
- binds: Bind<T, C> | Bind<T, C>[] | undefined;
697
- children?: Commit<unknown, CmdContext>[] | undefined;
698
- }
699
-
700
- const cleanCommit: (commit: Commit<unknown, CmdContext>) => void;
701
- ```
702
-
703
- A `Commit` groups the bindings created by mounting a view; `cleanCommit` recursively removes them all. `View.childCommits` carries these so a parent can tear a whole subtree down at once.
704
-
705
- ### Reactivity model
248
+ | `E1:<index>` | `at` | Array index out of range. |
249
+ | `E2` | `toBoundary` | Cannot build a boundary from an empty fragment. |
250
+ | `E3` | `replaceWithNode` | The old boundary has no `parentNode`. |
251
+ | `E4` | `patch` | A keyed command (`attr`, `style`, `styleProp`, `toggleClass`) was given no key. |
252
+ | `E5` | `patch` | `act` was given no function. |
253
+ | `E6` | `patch` | `on` was given no event type. |
254
+ | `E7` | `repeat` | The rendered items have no parent node. |
255
+ | `E8` | `repeat` | The temporary start/end fences were removed mid-update. |
256
+ | `E9` | `repeat` | There is no node after the fence to move before. |
257
+ | `E10` | `html` | The template is empty (an empty tagged template). |
258
+ | `E11:<expected>:<got>` | `html` | Interpolation count does not match the template's slots. |
259
+ | `E12` | `html` | Unmatched interpolation; a `Patch` must be in an attribute position and a `View`/text value in a content position. |
260
+
261
+ ## Reactivity model
706
262
 
707
263
  - **Batched.** When a signal changes, its bindings are queued, not run synchronously.
708
264
  - **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`.
709
265
  - **`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).
266
+ - **`notify` for in-place mutation.** `sig.notify()` re-runs dependents against the current value without setting a new one. Use it after mutating a held object or array in place; `update`/`forceUpdate` set a value. On a `DerivedSig`, `notify` schedules its consumers but does not itself recompute the derived value.
710
267
  - **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)`.
711
- - **Deep equality by default.** `update`, `compute`, and `repeat` compare with `isEqual`, so replacing `{a: 1}` with another `{a: 1}` is a no-op.
268
+ - **Deep equality by default.** `update`, `compute`, and `repeat` compare with `eq`, so replacing `{a: 1}` with another `{a: 1}` is a no-op.
712
269
 
713
270
  ## License
714
271