balises 0.2.1 → 0.4.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
@@ -4,15 +4,37 @@
4
4
  <img alt="balises" src="./assets/logo.svg" width="280">
5
5
  </picture>
6
6
 
7
- ### A minimal reactive HTML templating library. ~3.0KB gzipped.
7
+ ### A minimal reactive HTML templating library for building websites and web components. ~3.0KB gzipped.
8
+
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
+
11
+ **You can also use it as a standalone signals library** - the reactivity system works independently of the templating, making it useful for any JavaScript project that needs reactive state management.
8
12
 
9
13
  **[📚️ Documentation & Examples](https://elbywan.github.io/balises/)**
10
14
 
11
- ## Note
15
+ ## Preamble
16
+
17
+ > [!WARNING]
18
+ > 🚧 Use at your own discretion .
19
+
20
+ This library was built in a couple of days **using LLM assistance** as an experiment to see if it was possible to produce something high-quality and performant very quickly.
12
21
 
13
- This is a personal side project with limited maintenance. Most of the code was written with LLM assistance.
22
+ It all begun with me needing a lightweight reactive templating solution with zero dependencies for a non-critical work project at [Datadog](https://www.datadoghq.com/), and since I wanted to explore what modern AI-assisted development could achieve it was a good fit.
14
23
 
15
- 🚧 Use at your own discretion.
24
+ Ultimately it turns out that I am quite happy with the result! It is quite performant, ergonomic, has a very small bundle size, is thoroughly tested and suits my needs well. 🌟
25
+
26
+ **However, please be aware that this is a personal side project with limited maintenance and no guarantees of long-term support.**
27
+
28
+ ## Table of Contents
29
+
30
+ - [Installation](#installation)
31
+ - [Quick Start](#quick-start)
32
+ - [Building Web Components](#building-web-components)
33
+ - [Composable Function Components](#composable-function-components)
34
+ - [Template Syntax](#template-syntax)
35
+ - [Reactivity API](#reactivity-api)
36
+ - [Tree-Shaking / Modular Imports](#tree-shaking--modular-imports)
37
+ - [Benchmarks](#benchmarks)
16
38
 
17
39
  ## Installation
18
40
 
@@ -22,6 +44,8 @@ npm install balises
22
44
 
23
45
  ## Quick Start
24
46
 
47
+ Balises uses tagged template literals to create reactive HTML. Just interpolate signals into your markup and they'll automatically update the DOM when they change.
48
+
25
49
  ```ts
26
50
  import { html, signal } from "balises";
27
51
 
@@ -37,9 +61,72 @@ document.body.appendChild(fragment);
37
61
  // Call dispose() when done to clean up subscriptions
38
62
  ```
39
63
 
64
+ ## Building Web Components
65
+
66
+ Balises works naturally with the Web Components API. Just render templates in `connectedCallback` and clean up in `disconnectedCallback`.
67
+
68
+ ```ts
69
+ import { html, signal, effect } from "balises";
70
+
71
+ class Counter extends HTMLElement {
72
+ #count = signal(0);
73
+ #dispose?: () => void;
74
+
75
+ connectedCallback() {
76
+ // Auto-sync to localStorage
77
+ const syncEffect = effect(() => {
78
+ localStorage.setItem("counter", String(this.#count.value));
79
+ });
80
+
81
+ const { fragment, dispose } = html`
82
+ <div>
83
+ <p>Count: ${this.#count}</p>
84
+ <button @click=${() => this.#count.update((n) => n - 1)}>-</button>
85
+ <button @click=${() => this.#count.update((n) => n + 1)}>+</button>
86
+ </div>
87
+ `.render();
88
+
89
+ this.appendChild(fragment);
90
+ this.#dispose = () => {
91
+ syncEffect();
92
+ dispose();
93
+ };
94
+ }
95
+
96
+ disconnectedCallback() {
97
+ this.#dispose?.();
98
+ }
99
+ }
100
+
101
+ customElements.define("x-counter", Counter);
102
+ ```
103
+
104
+ Use it in your HTML:
105
+
106
+ ```html
107
+ <x-counter></x-counter>
108
+ ```
109
+
110
+ 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
+
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
+
40
127
  ## Template Syntax
41
128
 
42
- The `html` tagged template creates reactive DOM fragments.
129
+ 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.
43
130
 
44
131
  ### Interpolation Types
45
132
 
@@ -53,6 +140,20 @@ The `html` tagged template creates reactive DOM fragments.
53
140
 
54
141
  All interpolations accept reactive values (`Signal` or `Computed`) and will auto-update when they change.
55
142
 
143
+ ### Function Interpolation
144
+
145
+ Functions are wrapped in `computed()` automatically:
146
+
147
+ ```ts
148
+ const state = store({ count: 0 });
149
+
150
+ html`
151
+ <p>Count: ${() => state.count}</p>
152
+ <p>Doubled: ${() => state.count * 2}</p>
153
+ ${() => (state.count > 10 ? html`<p>High score!</p>` : null)}
154
+ `.render();
155
+ ```
156
+
56
157
  ### Nested Templates
57
158
 
58
159
  Templates can be nested, and arrays of templates are flattened:
@@ -62,14 +163,14 @@ const items = signal(["a", "b", "c"]);
62
163
 
63
164
  html`
64
165
  <ul>
65
- ${computed(() => items.value.map((item) => html`<li>${item}</li>`))}
166
+ ${() => items.value.map((item) => html`<li>${item}</li>`)}
66
167
  </ul>
67
168
  `.render();
68
169
  ```
69
170
 
70
171
  ### Efficient List Rendering with `each()`
71
172
 
72
- For lists that change frequently, use `each()` for keyed reconciliation. Templates are cached by key and reused:
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:
73
174
 
74
175
  ```ts
75
176
  import { html, signal, each } from "balises";
@@ -99,34 +200,20 @@ items.value[0].name.value = "Alicia";
99
200
  items.value = [...items.value].reverse();
100
201
  ```
101
202
 
102
- The `each()` helper has two forms:
103
-
104
- **With key function (recommended when objects may be recreated with same identity):**
105
-
106
- ```ts
107
- each(list, keyFn, renderFn);
108
- ```
109
-
110
- - `list` - A reactive array (Signal, Computed, or getter function)
111
- - `keyFn` - Extracts a unique key from each item: `(item, index) => key`
112
- - `renderFn` - Renders each item (called once per unique key)
113
-
114
- **Without key function (automatic keying):**
203
+ Signatures:
115
204
 
116
205
  ```ts
117
- each(list, renderFn);
206
+ each(list, keyFn, renderFn); // keyed by keyFn(item, index)
207
+ each(list, renderFn); // keyed by object reference or index
118
208
  ```
119
209
 
120
- - Objects use their reference as key (reordering works correctly)
121
- - Primitives use index as key (duplicates are handled correctly)
122
-
123
- For content updates without list reconciliation, use nested signals (like `name: signal("Alice")` above).
210
+ If you want to update item content without triggering list reconciliation, nest signals inside your items (like `name: signal("Alice")` above).
124
211
 
125
212
  ## Reactivity API
126
213
 
127
- ### `signal<T>(value)` / `new Signal<T>(value)`
214
+ ### `signal<T>(value)`
128
215
 
129
- Creates a reactive value container.
216
+ Wraps a value to make it reactive.
130
217
 
131
218
  ```ts
132
219
  const name = signal("world");
@@ -134,7 +221,7 @@ console.log(name.value); // "world"
134
221
  name.value = "everyone"; // Notifies subscribers
135
222
  ```
136
223
 
137
- **Updating based on current value:**
224
+ **Updating based on the current value:**
138
225
 
139
226
  ```ts
140
227
  const count = signal(0);
@@ -148,9 +235,9 @@ count.value = count.value + 1;
148
235
  count.value = count.value * 2;
149
236
  ```
150
237
 
151
- ### `computed<T>(fn)` / `new Computed<T>(fn)`
238
+ ### `computed<T>(fn)`
152
239
 
153
- Creates a derived value that auto-tracks dependencies.
240
+ Derives a value from other signals. Automatically tracks dependencies.
154
241
 
155
242
  ```ts
156
243
  const firstName = signal("John");
@@ -162,11 +249,11 @@ firstName.value = "Jane";
162
249
  console.log(fullName.value); // "Jane Doe"
163
250
  ```
164
251
 
165
- Computeds are lazy - they only recompute when accessed and when their dependencies have changed.
252
+ Computed values are lazy - they only recalculate when accessed and a dependency has changed.
166
253
 
167
254
  ### `effect(fn)`
168
255
 
169
- Creates a side effect that automatically re-runs when its dependencies change. Unlike `computed()`, effects run immediately and are intended for side effects like DOM updates, logging, or persistence.
256
+ Runs a side effect whenever its dependencies change. Under the hood, `effect()` is a computed with an automatic subscription, which makes it run eagerly on every dependency change rather than waiting to be accessed.
170
257
 
171
258
  ```ts
172
259
  import { signal, effect } from "balises";
@@ -183,14 +270,14 @@ count.value = 1; // Logs "Count is now: 1" and updates title
183
270
  dispose(); // Stop the effect
184
271
  ```
185
272
 
186
- **Use cases:**
273
+ Good for things like:
187
274
 
188
275
  - Syncing state to localStorage
189
- - Updating document.title or other DOM properties
276
+ - Updating document.title or other globals
190
277
  - Logging and analytics
191
- - Network requests triggered by state changes
278
+ - Network requests based on state
192
279
 
193
- Effects are automatically disposed when the component that created them is disposed (via the template's `dispose()` function).
280
+ When you call `dispose()` on a template, any effects created during rendering are cleaned up automatically.
194
281
 
195
282
  **Example: Auto-sync to localStorage**
196
283
 
@@ -207,7 +294,7 @@ favorites.value = [...favorites.value, "new item"];
207
294
 
208
295
  ### `store<T>(obj)`
209
296
 
210
- Proxy-based reactive wrapper. Nested plain objects are wrapped recursively.
297
+ A proxy-based alternative to signals. Nested plain objects become reactive automatically.
211
298
 
212
299
  ```ts
213
300
  const state = store({ count: 0, user: { name: "Alice" } });
@@ -215,7 +302,7 @@ state.count++; // Reactive
215
302
  state.user.name = "Bob"; // Also reactive (nested)
216
303
  ```
217
304
 
218
- **Note:** Array mutations like `push()`, `pop()`, `splice()` do **not** trigger reactivity. To update arrays reactively, reassign them:
305
+ **Note:** Array mutations like `push()`, `pop()`, `splice()` do **not** trigger reactivity. You need to reassign the array:
219
306
 
220
307
  ```ts
221
308
  const state = store({ items: [1, 2, 3] });
@@ -226,7 +313,7 @@ state.items.push(4);
226
313
  // ✅ Triggers reactivity
227
314
  state.items = [...state.items, 4];
228
315
 
229
- // Using update() for functional updates (works with both signal and store)
316
+ // Alternative: Use signal for arrays to get the .update() helper
230
317
  const items = signal([1, 2, 3]);
231
318
  items.update((arr) => [...arr, 4]);
232
319
  items.update((arr) => arr.filter((n) => n !== 2));
@@ -234,7 +321,7 @@ items.update((arr) => arr.filter((n) => n !== 2));
234
321
 
235
322
  ### `batch<T>(fn)`
236
323
 
237
- Batch multiple signal updates to defer subscriber notifications until the batch completes.
324
+ Batches multiple signal updates so subscribers only get notified once at the end.
238
325
 
239
326
  ```ts
240
327
  import { batch, signal } from "balises";
@@ -248,6 +335,28 @@ batch(() => {
248
335
  }); // Subscribers notified once after both updates
249
336
  ```
250
337
 
338
+ ### `scope(fn)`
339
+
340
+ Groups reactive primitives together so you can dispose them all at once.
341
+
342
+ ```ts
343
+ import { scope, signal, computed, effect } from "balises";
344
+
345
+ const [state, dispose] = scope(() => {
346
+ const count = signal(0);
347
+ const doubled = computed(() => count.value * 2);
348
+ effect(() => console.log(doubled.value));
349
+ return { count, doubled };
350
+ });
351
+
352
+ // Use state.count, state.doubled...
353
+
354
+ // Later: clean up everything at once
355
+ dispose();
356
+ ```
357
+
358
+ Handy for components or temporary reactive contexts where you need bulk cleanup.
359
+
251
360
  ### `isSignal(value)`
252
361
 
253
362
  Type guard to check if a value is reactive (`Signal` or `Computed`).
@@ -274,7 +383,7 @@ unsubscribe(); // Stop listening
274
383
 
275
384
  ### `.dispose()`
276
385
 
277
- Dispose a computed, removing all dependency links.
386
+ Stops a computed from tracking dependencies and frees memory.
278
387
 
279
388
  ```ts
280
389
  const doubled = computed(() => count.value * 2);
@@ -283,21 +392,40 @@ doubled.dispose(); // Stops tracking, frees memory
283
392
 
284
393
  ## Tree-Shaking / Modular Imports
285
394
 
286
- The library supports granular imports for optimal bundle size:
395
+ You can import just what you need to keep bundle size down:
287
396
 
288
397
  ```ts
289
398
  // Full library (~3.0KB gzipped)
290
399
  import { html, signal, computed, effect } from "balises";
291
400
 
292
- // Signals only
293
- import { signal, computed, effect, store, batch } from "balises/signals";
401
+ // Signals only (no HTML templating - use in any JS project)
402
+ import { signal, computed, effect, store, batch, scope } from "balises/signals";
294
403
 
295
404
  // Individual modules
296
405
  import { signal } from "balises/signals/signal";
297
406
  import { computed } from "balises/signals/computed";
298
407
  import { effect } from "balises/signals/effect";
299
408
  import { store } from "balises/signals/store";
300
- import { batch } from "balises/signals/context";
409
+ import { batch, scope } from "balises/signals/context";
410
+ ```
411
+
412
+ ### Using as a Standalone Signals Library
413
+
414
+ The reactivity system is completely independent of the HTML templating. You can use just the signals in Node.js, Electron, or any JavaScript environment:
415
+
416
+ ```ts
417
+ import { signal, computed, effect } from "balises/signals";
418
+
419
+ // Reactive state management without DOM
420
+ const users = signal([]);
421
+ const userCount = computed(() => users.value.length);
422
+
423
+ effect(() => {
424
+ console.log(`Total users: ${userCount.value}`);
425
+ });
426
+
427
+ users.value = [{ name: "Alice" }, { name: "Bob" }];
428
+ // Logs: "Total users: 2"
301
429
  ```
302
430
 
303
431
  ## Full Example
@@ -314,7 +442,7 @@ class Counter extends HTMLElement {
314
442
 
315
443
  const { fragment, dispose } = html`
316
444
  <div>
317
- <p>Count: ${computed(() => state.count)} (double: ${double})</p>
445
+ <p>Count: ${() => state.count} (double: ${double})</p>
318
446
  <button @click=${() => state.count++}>+</button>
319
447
  <button @click=${() => state.count--}>-</button>
320
448
  </div>
@@ -368,6 +496,89 @@ class Counter extends HTMLElement {
368
496
  }
369
497
  ```
370
498
 
499
+ <!-- BENCHMARK_RESULTS_START -->
500
+
501
+ ## Benchmarks
502
+
503
+ Performance comparison of Balises against other popular reactive libraries. Benchmarks run in isolated processes to prevent V8 JIT contamination.
504
+
505
+ **Test Environment:**
506
+
507
+ - Node.js with V8 engine
508
+ - Each test runs in a separate process (isolated mode)
509
+ - **10 warmup runs** to stabilize JIT
510
+ - **100 iterations per test**, keeping middle 20 (discarding 40 best + 40 worst to reduce outliers)
511
+ - Tests measure pure reactive propagation (not DOM rendering)
512
+
513
+ **Scoring Methodology:**
514
+
515
+ - Overall ranking uses a combined score (50% average rank + 50% normalized average time)
516
+ - This ensures both consistency across scenarios (rank) and absolute performance (time) are valued equally
517
+ - "vs Fastest" compares average time to the fastest library
518
+
519
+ ### Overall Performance
520
+
521
+ ```
522
+ ┌───────┬───────────────────┬──────────┬───────────────┬──────────────────┐
523
+ │ Rank │ Library │ Avg Rank │ Avg Time (μs) │ vs Fastest │
524
+ ├───────┼───────────────────┼──────────┼───────────────┼──────────────────┤
525
+ │ #1 🏆 │ preact@1.12.1 │ 1.5 │ 47.33 │ 1.00x (baseline) │
526
+ ├───────┼───────────────────┼──────────┼───────────────┼──────────────────┤
527
+ │ #2 │ balises@0.4.0 │ 1.5 │ 71.15 │ 1.50x │
528
+ ├───────┼───────────────────┼──────────┼───────────────┼──────────────────┤
529
+ │ #3 │ vue@3.5.26 │ 3.2 │ 80.96 │ 1.71x │
530
+ ├───────┼───────────────────┼──────────┼───────────────┼──────────────────┤
531
+ │ #4 │ maverick@6.0.0 │ 3.8 │ 89.56 │ 1.89x │
532
+ ├───────┼───────────────────┼──────────┼───────────────┼──────────────────┤
533
+ │ #5 │ solid@1.9.10 │ 5.2 │ 214.92 │ 4.54x │
534
+ ├───────┼───────────────────┼──────────┼───────────────┼──────────────────┤
535
+ │ #6 │ mobx@6.15.0 │ 6.0 │ 623.36 │ 13.17x │
536
+ ├───────┼───────────────────┼──────────┼───────────────┼──────────────────┤
537
+ │ #7 │ hyperactiv@0.11.3 │ 6.8 │ 695.71 │ 14.70x │
538
+ └───────┴───────────────────┴──────────┴───────────────┴──────────────────┘
539
+ ```
540
+
541
+ ### Performance by Scenario
542
+
543
+ ```
544
+ ┌───────────────────┬───────────────┬─────────────┬────────────────┬────────────────────┬─────────────┬──────────────┬──────────┐
545
+ │ Library │ S1: 1: Layers │ S2: 2: Wide │ S3: 3: Diamond │ S4: 4: Conditional │ S5: 5: List │ S6: 6: Batch │ Avg Rank │
546
+ ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
547
+ │ preact@1.12.1 │ #1 🏆 │ #1 🏆 │ #2 │ #1 🏆 │ #2 │ #2 │ 1.5 │
548
+ ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
549
+ │ balises@0.4.0 │ #2 │ #2 │ #1 🏆 │ #2 │ #1 🏆 │ #1 🏆 │ 1.5 │
550
+ ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
551
+ │ vue@3.5.26 │ #3 │ #3 │ #3 │ #3 │ #3 │ #4 │ 3.2 │
552
+ ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
553
+ │ maverick@6.0.0 │ #4 │ #4 │ #4 │ #4 │ #4 │ #3 │ 3.8 │
554
+ ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
555
+ │ solid@1.9.10 │ #5 │ #6 │ #5 │ #5 │ #5 │ #5 │ 5.2 │
556
+ ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
557
+ │ mobx@6.15.0 │ #7 │ #5 │ #6 │ #6 │ #6 │ #6 │ 6.0 │
558
+ ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
559
+ │ hyperactiv@0.11.3 │ #6 │ #7 │ #7 │ #7 │ #7 │ #7 │ 6.8 │
560
+ └───────────────────┴───────────────┴─────────────┴────────────────┴────────────────────┴─────────────┴──────────────┴──────────┘
561
+ ```
562
+
563
+ **Scenarios:**
564
+
565
+ - **S1: Layers** - Deep dependency chains (A→B→C→D...)
566
+ - **S2: Wide** - Many independent signals updating in parallel
567
+ - **S3: Diamond** - Multiple paths to same computed (diamond dependencies)
568
+ - **S4: Conditional** - Dynamic subscriptions (like v-if logic)
569
+ - **S5: List** - List operations with filtering (like v-for patterns)
570
+ - **S6: Batch** - Batched/transactional updates
571
+
572
+ **Interpretation:**
573
+
574
+ - Balises performs well across all scenarios, particularly excelling at diamond dependencies, list operations, and batching
575
+ - These are synthetic benchmarks measuring pure reactivity - real apps should consider the whole picture (ecosystem, docs, community, etc.)
576
+ - Lower rank = better performance
577
+
578
+ _Last updated: 2025-12-31_
579
+
580
+ <!-- BENCHMARK_RESULTS_END -->
581
+
371
582
  ## Scripts
372
583
 
373
584
  ```bash