balises 0.5.0 → 0.7.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.
Files changed (40) hide show
  1. package/README.md +204 -56
  2. package/dist/balises.esm.js +148 -129
  3. package/dist/balises.esm.js.map +1 -1
  4. package/dist/balises.iife.js +147 -129
  5. package/dist/balises.iife.js.map +1 -1
  6. package/dist/balises.iife.min.js +1 -1
  7. package/dist/balises.iife.min.js.map +1 -1
  8. package/dist/esm/async.d.ts +64 -0
  9. package/dist/esm/async.d.ts.map +1 -0
  10. package/dist/esm/async.js +201 -0
  11. package/dist/esm/async.js.map +1 -0
  12. package/dist/esm/each.d.ts +67 -0
  13. package/dist/esm/each.d.ts.map +1 -0
  14. package/dist/esm/each.js +302 -0
  15. package/dist/esm/each.js.map +1 -0
  16. package/dist/esm/index.d.ts +2 -2
  17. package/dist/esm/index.d.ts.map +1 -1
  18. package/dist/esm/index.js +1 -1
  19. package/dist/esm/index.js.map +1 -1
  20. package/dist/esm/signals/computed.d.ts +3 -0
  21. package/dist/esm/signals/computed.d.ts.map +1 -1
  22. package/dist/esm/signals/computed.js +31 -33
  23. package/dist/esm/signals/computed.js.map +1 -1
  24. package/dist/esm/signals/context.d.ts +9 -0
  25. package/dist/esm/signals/context.d.ts.map +1 -1
  26. package/dist/esm/signals/context.js +6 -0
  27. package/dist/esm/signals/context.js.map +1 -1
  28. package/dist/esm/signals/index.d.ts +1 -1
  29. package/dist/esm/signals/index.d.ts.map +1 -1
  30. package/dist/esm/signals/index.js +5 -3
  31. package/dist/esm/signals/index.js.map +1 -1
  32. package/dist/esm/signals/signal.d.ts +29 -0
  33. package/dist/esm/signals/signal.d.ts.map +1 -1
  34. package/dist/esm/signals/signal.js +46 -3
  35. package/dist/esm/signals/signal.js.map +1 -1
  36. package/dist/esm/template.d.ts +35 -41
  37. package/dist/esm/template.d.ts.map +1 -1
  38. package/dist/esm/template.js +81 -119
  39. package/dist/esm/template.js.map +1 -1
  40. package/package.json +10 -2
package/README.md CHANGED
@@ -4,7 +4,7 @@
4
4
  <img alt="balises" src="./assets/logo.svg" width="280">
5
5
  </picture>
6
6
 
7
- ### A minimal reactive HTML templating library for building websites and web components. ~3.0KB gzipped.
7
+ ### A minimal reactive HTML templating library for building websites and web components. ~2.8KB gzipped.
8
8
 
9
9
  Balises gives you reactive signals and HTML templates without the framework overhead. Works great with custom elements, vanilla JavaScript projects, or anywhere you need dynamic UIs but don't want to pull in React.
10
10
 
@@ -29,10 +29,12 @@ Ultimately it turns out that I am quite happy with the result! It is quite perfo
29
29
 
30
30
  - [Installation](#installation)
31
31
  - [Quick Start](#quick-start)
32
- - [Building Web Components](#building-web-components)
33
- - [Composable Function Components](#composable-function-components)
32
+ - [Function Components](#function-components)
33
+ - [Async Generators](#async-generators)
34
+ - [DOM Preservation on Restart](#dom-preservation-on-restart)
34
35
  - [Template Syntax](#template-syntax)
35
36
  - [Reactivity API](#reactivity-api)
37
+ - [Web Components](#web-components)
36
38
  - [Tree-Shaking / Modular Imports](#tree-shaking--modular-imports)
37
39
  - [Benchmarks](#benchmarks)
38
40
 
@@ -61,9 +63,116 @@ document.body.appendChild(fragment);
61
63
  // Call dispose() when done to clean up subscriptions
62
64
  ```
63
65
 
64
- ## Building Web Components
66
+ ## Function Components
65
67
 
66
- Balises works naturally with the Web Components API. Just render templates in `connectedCallback` and clean up in `disconnectedCallback`.
68
+ The recommended way to build UIs with balises is using function components - plain functions that receive props and return templates. This pattern is simpler than web components and better for composition.
69
+
70
+ ```ts
71
+ import { html, store } from "balises";
72
+
73
+ // Define a reusable component as a function
74
+ function Counter({ state, onIncrement }) {
75
+ return html`
76
+ <div class="counter">
77
+ <span>Count: ${() => state.count}</span>
78
+ <button @click=${onIncrement}>+1</button>
79
+ </div>
80
+ `;
81
+ }
82
+
83
+ // Create shared state
84
+ const state = store({ count: 0 });
85
+
86
+ // Compose components together
87
+ const { fragment, dispose } = html`
88
+ <div class="app">
89
+ <h1>My App</h1>
90
+ ${Counter({ state, onIncrement: () => state.count++ })}
91
+ ${Counter({ state, onIncrement: () => (state.count += 10) })}
92
+ </div>
93
+ `.render();
94
+
95
+ document.body.appendChild(fragment);
96
+ ```
97
+
98
+ Pass the store itself (not individual values) so reactivity works.
99
+
100
+ Wrap expressions in functions like `${() => state.count}` to track dependencies.
101
+
102
+ ## Async Generators
103
+
104
+ Async generator functions handle loading states and async data flows. The generator automatically restarts when any tracked signal changes.
105
+
106
+ **Note:** Async generators are opt-in via `balises/async` to keep the base bundle small.
107
+
108
+ ```ts
109
+ import { html as baseHtml, signal } from "balises";
110
+ import asyncPlugin from "balises/async";
111
+
112
+ const html = baseHtml.with(asyncPlugin);
113
+ const userId = signal(1);
114
+
115
+ html`
116
+ ${async function* () {
117
+ const id = userId.value; // Track dependency - restarts when userId changes
118
+
119
+ yield html`<div class="loading">Loading...</div>`;
120
+
121
+ const user = await fetch(`/api/users/${id}`).then((r) => r.json());
122
+ yield html`<div class="profile">${user.name}</div>`;
123
+ }}
124
+ `.render();
125
+
126
+ // Changing userId restarts the generator automatically
127
+ userId.value = 2;
128
+ ```
129
+
130
+ Async generators replace the entire yielded content on each yield. For surgical updates within a stable DOM structure, use reactive bindings (`${() => state.value}`) instead.
131
+
132
+ ### DOM Preservation on Restart
133
+
134
+ When a signal changes, the generator restarts and normally replaces the DOM. To preserve existing DOM and enable surgical updates via reactive bindings, return the `settled` parameter:
135
+
136
+ ```ts
137
+ import { html as baseHtml, signal, store } from "balises";
138
+ import asyncPlugin, { type RenderedContent } from "balises/async";
139
+
140
+ const html = baseHtml.with(asyncPlugin);
141
+ const userId = signal(1);
142
+ const state = store({ user: null, loading: false });
143
+
144
+ html`
145
+ ${async function* (settled?: RenderedContent) {
146
+ const id = userId.value;
147
+
148
+ if (settled) {
149
+ // Restart: update state, preserve existing DOM
150
+ state.loading = true;
151
+ const user = await fetch(`/api/users/${id}`).then((r) => r.json());
152
+ state.user = user;
153
+ state.loading = false;
154
+ return settled;
155
+ }
156
+
157
+ // First load: render with reactive bindings
158
+ yield html`<div class="skeleton">Loading...</div>`;
159
+ const user = await fetch(`/api/users/${id}`).then((r) => r.json());
160
+ state.user = user;
161
+ return html`
162
+ <div class="profile">
163
+ <h2>${() => state.user?.name}</h2>
164
+ <span>${() => (state.loading ? "Updating..." : "")}</span>
165
+ </div>
166
+ `;
167
+ }}
168
+ `.render();
169
+ ```
170
+
171
+ The `settled` parameter is `undefined` on first run, and contains an opaque handle to the previous render on restarts. Returning it preserves existing DOM nodes and reactive bindings.
172
+
173
+ ## Web Components
174
+
175
+ For reusable widgets that need encapsulation or browser-native lifecycle, balises works naturally with the Web Components API:
67
176
 
68
177
  ```ts
69
178
  import { html, signal, effect } from "balises";
@@ -109,21 +218,6 @@ Use it in your HTML:
109
218
 
110
219
  You can build entire apps this way, or just add interactive widgets to existing pages. No build step required if you use it from a CDN.
111
220
 
112
- ## Composable Function Components
113
-
114
- You can also use plain functions that return templates. Pass the store and access its properties in function wrappers to keep things reactive:
115
-
116
- ```ts
117
- function Counter({ state }) {
118
- return html`
119
- <button @click=${() => state.count++}>${() => state.count}</button>
120
- `;
121
- }
122
-
123
- const state = store({ count: 0 });
124
- html`<div>${Counter({ state })}</div>`.render();
125
- ```
126
-
127
221
  ## Template Syntax
128
222
 
129
223
  The `html` tagged template creates reactive DOM fragments. When you interpolate a signal, that specific part of the DOM updates automatically when the signal changes.
@@ -170,44 +264,63 @@ html`
170
264
 
171
265
  ### Efficient List Rendering with `each()`
172
266
 
173
- When rendering lists that change frequently, use `each()` for keyed reconciliation. It caches templates by key so items can be reordered, added, or removed without recreating the DOM nodes:
267
+ When rendering lists that change frequently, use `each()` for keyed reconciliation. It caches templates by key so items can be reordered, added, or removed without recreating the DOM nodes.
268
+
269
+ **Note:** The `each()` function is opt-in via the `balises/each` import to keep the base bundle small. Use `html.with(eachPlugin)` to enable keyed list support.
270
+
271
+ **Two forms:**
272
+
273
+ 1. **Two-arg form** (object reference as key): Render receives raw item. DOM reused only when same object reference appears.
274
+ 2. **Three-arg form** (explicit key function): Render receives `ReadonlySignal<T>`. DOM reused when keys match, even with new object references.
174
275
 
175
276
  ```ts
176
- import { html, signal, each } from "balises";
277
+ import { html as baseHtml, signal } from "balises";
278
+ import eachPlugin, { each } from "balises/each";
177
279
 
178
- const items = signal([
179
- { id: 1, name: signal("Alice") },
180
- { id: 2, name: signal("Bob") },
280
+ const html = baseHtml.with(eachPlugin);
281
+
282
+ // Three-arg form: explicit key, receives ReadonlySignal
283
+ // DOM is preserved when keys match (ideal for API data)
284
+ const users = signal([
285
+ { id: 1, name: "Alice" },
286
+ { id: 2, name: "Bob" },
181
287
  ]);
182
288
 
183
289
  html`
184
290
  <ul>
185
291
  ${each(
186
- items,
187
- (item) => item.id,
188
- (item) => html`<li>${item.name}</li>`,
292
+ users,
293
+ (user) => user.id,
294
+ (userSignal) => html`<li>${() => userSignal.value.name}</li>`,
189
295
  )}
190
296
  </ul>
191
297
  `.render();
192
298
 
193
- // Append: only creates one new node
194
- items.value = [...items.value, { id: 3, name: signal("Carol") }];
299
+ // Refetch from API - DOM preserved, content updated via signal
300
+ users.value = [
301
+ { id: 1, name: "Alicia" }, // Same key, new object - DOM preserved!
302
+ { id: 2, name: "Bobby" },
303
+ { id: 3, name: "Carol" }, // New key - new DOM created
304
+ ];
195
305
 
196
- // Update content: surgical update, no list diffing
197
- items.value[0].name.value = "Alicia";
306
+ // Two-arg form: object reference as key, receives raw item
307
+ const items = signal([{ name: "Item 1" }, { name: "Item 2" }]);
198
308
 
199
- // Reorder: moves existing nodes, no recreation
200
- items.value = [...items.value].reverse();
309
+ html`
310
+ <ul>
311
+ ${each(items, (item) => html`<li>${item.name}</li>`)}
312
+ </ul>
313
+ `.render();
201
314
  ```
202
315
 
203
316
  Signatures:
204
317
 
205
318
  ```ts
206
- each(list, keyFn, renderFn); // keyed by keyFn(item, index)
207
- each(list, renderFn); // keyed by object reference or index
319
+ each(list, keyFn, renderFn); // Three-arg: keyFn extracts key, renderFn receives ReadonlySignal<T>
320
+ each(list, renderFn); // Two-arg: object reference as key, renderFn receives raw T
208
321
  ```
209
322
 
210
- If you want to update item content without triggering list reconciliation, nest signals inside your items (like `name: signal("Alice")` above).
323
+ **Important:** When using the three-arg form, access item properties through `itemSignal.value` and wrap in `() => ...` for reactive updates.
211
324
 
212
325
  ## Reactivity API
213
326
 
@@ -235,6 +348,16 @@ count.value = count.value + 1;
235
348
  count.value = count.value * 2;
236
349
  ```
237
350
 
351
+ **Reading without tracking dependencies:**
352
+
353
+ ```ts
354
+ const count = signal(0);
355
+
356
+ // peek() reads without creating a dependency
357
+ // Useful in event handlers where you don't want reactivity
358
+ button.onclick = () => console.log(count.peek());
359
+ ```
360
+
238
361
  ### `computed<T>(fn)`
239
362
 
240
363
  Derives a value from other signals. Automatically tracks dependencies.
@@ -414,7 +537,7 @@ doubled.dispose(); // Stops tracking, frees memory
414
537
  You can import just what you need to keep bundle size down:
415
538
 
416
539
  ```ts
417
- // Full library (~3.0KB gzipped)
540
+ // Full library (~2.8KB gzipped)
418
541
  import { html, signal, computed, effect } from "balises";
419
542
 
420
543
  // Signals only (no HTML templating - use in any JS project)
@@ -428,6 +551,31 @@ import { store } from "balises/signals/store";
428
551
  import { batch, scope } from "balises/signals/context";
429
552
  ```
430
553
 
554
+ ### Template Plugins
555
+
556
+ The `each()` and async generator features are provided as opt-in plugins to keep the base bundle minimal. Use `html.with()` to compose plugins:
557
+
558
+ ```ts
559
+ // With each() support for keyed lists
560
+ import { html as baseHtml } from "balises";
561
+ import eachPlugin, { each } from "balises/each";
562
+
563
+ const html = baseHtml.with(eachPlugin);
564
+
565
+ // With async generator support
566
+ import { html as baseHtml } from "balises";
567
+ import asyncPlugin from "balises/async";
568
+
569
+ const html = baseHtml.with(asyncPlugin);
570
+
571
+ // With both plugins
572
+ import { html as baseHtml } from "balises";
573
+ import eachPlugin, { each } from "balises/each";
574
+ import asyncPlugin from "balises/async";
575
+
576
+ const html = baseHtml.with(eachPlugin, asyncPlugin);
577
+ ```
578
+
431
579
  ### Using as a Standalone Signals Library
432
580
 
433
581
  The reactivity system is completely independent of the HTML templating. You can use just the signals in Node.js, Electron, or any JavaScript environment:
@@ -541,23 +689,23 @@ Performance comparison of Balises against other popular reactive libraries. Benc
541
689
  ┌───────┬───────────────────┬───────┬───────────────┬──────────────────┐
542
690
  │ Rank │ Library │ Score │ Avg Time (μs) │ vs Fastest │
543
691
  ├───────┼───────────────────┼───────┼───────────────┼──────────────────┤
544
- │ #1 🏆 │ preact@1.12.1 │ 0.000 │ 47.70 │ 1.00x (baseline) │
692
+ │ #1 🏆 │ preact@1.12.1 │ 0.000 │ 63.51 │ 1.00x (baseline) │
545
693
  ├───────┼───────────────────┼───────┼───────────────┼──────────────────┤
546
- │ #2 │ balises@0.4.1 │ 0.02770.32 │ 1.47x
694
+ │ #2 │ balises@0.6.0 │ 0.02386.17 │ 1.36x
547
695
  ├───────┼───────────────────┼───────┼───────────────┼──────────────────┤
548
- │ #3 │ vue@3.5.26 │ 0.09878.02 │ 1.64x
696
+ │ #3 │ vue@3.5.26 │ 0.09395.06 │ 1.50x
549
697
  ├───────┼───────────────────┼───────┼───────────────┼──────────────────┤
550
- │ #4 │ maverick@6.0.0 │ 0.15592.49 │ 1.94x
698
+ │ #4 │ maverick@6.0.0 │ 0.143122.79 │ 1.93x
551
699
  ├───────┼───────────────────┼───────┼───────────────┼──────────────────┤
552
- │ #5 │ usignal@0.10.0 │ 0.205105.46 │ 2.21x
700
+ │ #5 │ usignal@0.10.0 │ 0.180134.11 │ 2.11x
553
701
  ├───────┼───────────────────┼───────┼───────────────┼──────────────────┤
554
- │ #6 │ angular@19.2.17 │ 0.219122.42 │ 2.57x
702
+ │ #6 │ angular@19.2.17 │ 0.201154.19 │ 2.43x
555
703
  ├───────┼───────────────────┼───────┼───────────────┼──────────────────┤
556
- │ #7 │ solid@1.9.10 │ 0.376217.29 │ 4.56x
704
+ │ #7 │ solid@1.9.10 │ 0.340260.44 │ 4.10x
557
705
  ├───────┼───────────────────┼───────┼───────────────┼──────────────────┤
558
- │ #8 │ mobx@6.15.0 │ 0.913638.9213.40x
706
+ │ #8 │ mobx@6.15.0 │ 0.857896.4814.12x
559
707
  ├───────┼───────────────────┼───────┼───────────────┼──────────────────┤
560
- │ #9 │ hyperactiv@0.11.3 │ 1.000 │ 683.44 14.33x
708
+ │ #9 │ hyperactiv@0.11.3 │ 1.000 │ 1029.95 16.22x
561
709
  └───────┴───────────────────┴───────┴───────────────┴──────────────────┘
562
710
  ```
563
711
 
@@ -567,21 +715,21 @@ Performance comparison of Balises against other popular reactive libraries. Benc
567
715
  ┌───────────────────┬───────────────┬─────────────┬────────────────┬────────────────────┬─────────────┬──────────────┬──────────┐
568
716
  │ Library │ S1: 1: Layers │ S2: 2: Wide │ S3: 3: Diamond │ S4: 4: Conditional │ S5: 5: List │ S6: 6: Batch │ Avg Rank │
569
717
  ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
570
- │ preact@1.12.1 │ #1 🏆 │ #1 🏆 │ #2 │ #1 🏆 │ #2 │ #2 │ 1.5 │
718
+ │ preact@1.12.1 │ #1 🏆 │ #2 │ #2 │ #1 🏆 │ #1 🏆 │ #2 │ 1.5 │
571
719
  ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
572
- │ balises@0.4.1 │ #2 │ #2 │ #1 🏆 │ #2 │ #1 🏆 │ #1 🏆 │ 1.5
720
+ │ balises@0.6.0 │ #3 │ #1 🏆 │ #1 🏆 │ #2 │ #2 │ #1 🏆 │ 1.7
573
721
  ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
574
- │ vue@3.5.26 │ #3 │ #3 │ #3 │ #3 │ #3 │ #5 │ 3.3
722
+ │ vue@3.5.26 │ #2 │ #3 │ #5 │ #3 │ #3 │ #5 │ 3.5
575
723
  ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
576
- │ maverick@6.0.0 │ #4 │ #5 │ #5 │ #4 │ #5 │ #4 │ 4.5
724
+ │ maverick@6.0.0 │ #5 │ #5 │ #4 │ #4 │ #4 │ #4 │ 4.3
577
725
  ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
578
- │ usignal@0.10.0 │ #5 │ #4 │ #4 │ #5 │ #8 │ #7 │ 5.5
726
+ │ usignal@0.10.0 │ #4 │ #4 │ #3 │ #5 │ #8 │ #7 │ 5.2
579
727
  ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
580
- │ angular@19.2.17 │ #6 │ #6 │ #7 │ #6 │ #4 │ #3 │ 5.3 │
728
+ │ angular@19.2.17 │ #6 │ #6 │ #6 │ #6 │ #5 │ #3 │ 5.3 │
581
729
  ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
582
- │ solid@1.9.10 │ #7 │ #8 │ #6 │ #7 │ #6 │ #6 │ 6.7
730
+ │ solid@1.9.10 │ #7 │ #8 │ #7 │ #7 │ #7 │ #6 │ 7.0
583
731
  ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
584
- │ mobx@6.15.0 │ #9 │ #7 │ #8 │ #8 │ #7 │ #8 │ 7.8
732
+ │ mobx@6.15.0 │ #9 │ #7 │ #8 │ #8 │ #6 │ #8 │ 7.7
585
733
  ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
586
734
  │ hyperactiv@0.11.3 │ #8 │ #9 │ #9 │ #9 │ #9 │ #9 │ 8.8 │
587
735
  └───────────────────┴───────────────┴─────────────┴────────────────┴────────────────────┴─────────────┴──────────────┴──────────┘
@@ -602,7 +750,7 @@ Performance comparison of Balises against other popular reactive libraries. Benc
602
750
  - These are synthetic benchmarks measuring pure reactivity - real apps should consider the whole picture (ecosystem, docs, community, etc.)
603
751
  - Lower rank = better performance
604
752
 
605
- _Last updated: 2026-01-02_
753
+ _Last updated: 2026-01-04_
606
754
 
607
755
  <!-- BENCHMARK_RESULTS_END -->
608
756