balises 0.2.0 → 0.3.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,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. ~2.9KB gzipped.
7
+ ### A minimal reactive HTML templating library. ~3.0KB gzipped.
8
8
 
9
9
  **[📚️ Documentation & Examples](https://elbywan.github.io/balises/)**
10
10
 
@@ -28,7 +28,9 @@ import { html, signal } from "balises";
28
28
  const count = signal(0);
29
29
 
30
30
  const { fragment, dispose } = html`
31
- <button @click=${() => count.value++}>Clicked ${count} times</button>
31
+ <button @click=${() => count.update((n) => n + 1)}>
32
+ Clicked ${count} times
33
+ </button>
32
34
  `.render();
33
35
 
34
36
  document.body.appendChild(fragment);
@@ -132,6 +134,20 @@ console.log(name.value); // "world"
132
134
  name.value = "everyone"; // Notifies subscribers
133
135
  ```
134
136
 
137
+ **Updating based on current value:**
138
+
139
+ ```ts
140
+ const count = signal(0);
141
+
142
+ // Using update() for functional updates
143
+ count.update((n) => n + 1);
144
+ count.update((n) => n * 2);
145
+
146
+ // Equivalent to:
147
+ count.value = count.value + 1;
148
+ count.value = count.value * 2;
149
+ ```
150
+
135
151
  ### `computed<T>(fn)` / `new Computed<T>(fn)`
136
152
 
137
153
  Creates a derived value that auto-tracks dependencies.
@@ -148,6 +164,47 @@ console.log(fullName.value); // "Jane Doe"
148
164
 
149
165
  Computeds are lazy - they only recompute when accessed and when their dependencies have changed.
150
166
 
167
+ ### `effect(fn)`
168
+
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.
170
+
171
+ ```ts
172
+ import { signal, effect } from "balises";
173
+
174
+ const count = signal(0);
175
+
176
+ // Runs immediately, then whenever count changes
177
+ const dispose = effect(() => {
178
+ console.log("Count is now:", count.value);
179
+ document.title = `Count: ${count.value}`;
180
+ });
181
+
182
+ count.value = 1; // Logs "Count is now: 1" and updates title
183
+ dispose(); // Stop the effect
184
+ ```
185
+
186
+ **Use cases:**
187
+
188
+ - Syncing state to localStorage
189
+ - Updating document.title or other DOM properties
190
+ - Logging and analytics
191
+ - Network requests triggered by state changes
192
+
193
+ Effects are automatically disposed when the component that created them is disposed (via the template's `dispose()` function).
194
+
195
+ **Example: Auto-sync to localStorage**
196
+
197
+ ```ts
198
+ const favorites = signal([]);
199
+
200
+ effect(() => {
201
+ localStorage.setItem("favorites", JSON.stringify(favorites.value));
202
+ });
203
+
204
+ // localStorage automatically updates whenever favorites changes
205
+ favorites.value = [...favorites.value, "new item"];
206
+ ```
207
+
151
208
  ### `store<T>(obj)`
152
209
 
153
210
  Proxy-based reactive wrapper. Nested plain objects are wrapped recursively.
@@ -158,6 +215,23 @@ state.count++; // Reactive
158
215
  state.user.name = "Bob"; // Also reactive (nested)
159
216
  ```
160
217
 
218
+ **Note:** Array mutations like `push()`, `pop()`, `splice()` do **not** trigger reactivity. To update arrays reactively, reassign them:
219
+
220
+ ```ts
221
+ const state = store({ items: [1, 2, 3] });
222
+
223
+ // ❌ Does NOT trigger reactivity
224
+ state.items.push(4);
225
+
226
+ // ✅ Triggers reactivity
227
+ state.items = [...state.items, 4];
228
+
229
+ // Alternative: Use signal for arrays if you want .update() method
230
+ const items = signal([1, 2, 3]);
231
+ items.update((arr) => [...arr, 4]);
232
+ items.update((arr) => arr.filter((n) => n !== 2));
233
+ ```
234
+
161
235
  ### `batch<T>(fn)`
162
236
 
163
237
  Batch multiple signal updates to defer subscriber notifications until the batch completes.
@@ -174,6 +248,28 @@ batch(() => {
174
248
  }); // Subscribers notified once after both updates
175
249
  ```
176
250
 
251
+ ### `scope(fn)`
252
+
253
+ Create a disposal scope that automatically collects all computeds and effects created within, allowing cleanup with a single `dispose()` call.
254
+
255
+ ```ts
256
+ import { scope, signal, computed, effect } from "balises";
257
+
258
+ const [state, dispose] = scope(() => {
259
+ const count = signal(0);
260
+ const doubled = computed(() => count.value * 2);
261
+ effect(() => console.log(doubled.value));
262
+ return { count, doubled };
263
+ });
264
+
265
+ // Use state.count, state.doubled...
266
+
267
+ // Later: clean up everything at once
268
+ dispose();
269
+ ```
270
+
271
+ Useful for components, temporary reactive contexts, or any scenario where you want automatic cleanup of multiple reactive primitives.
272
+
177
273
  ### `isSignal(value)`
178
274
 
179
275
  Type guard to check if a value is reactive (`Signal` or `Computed`).
@@ -212,17 +308,18 @@ doubled.dispose(); // Stops tracking, frees memory
212
308
  The library supports granular imports for optimal bundle size:
213
309
 
214
310
  ```ts
215
- // Full library (~2.9KB gzipped)
216
- import { html, signal, computed } from "balises";
311
+ // Full library (~3.0KB gzipped)
312
+ import { html, signal, computed, effect } from "balises";
217
313
 
218
314
  // Signals only
219
- import { signal, computed, store, batch } from "balises/signals";
315
+ import { signal, computed, effect, store, batch, scope } from "balises/signals";
220
316
 
221
317
  // Individual modules
222
318
  import { signal } from "balises/signals/signal";
223
319
  import { computed } from "balises/signals/computed";
320
+ import { effect } from "balises/signals/effect";
224
321
  import { store } from "balises/signals/store";
225
- import { batch } from "balises/signals/context";
322
+ import { batch, scope } from "balises/signals/context";
226
323
  ```
227
324
 
228
325
  ## Full Example
@@ -257,6 +354,125 @@ class Counter extends HTMLElement {
257
354
  customElements.define("x-counter", Counter);
258
355
  ```
259
356
 
357
+ **With `signal.update()` for functional updates:**
358
+
359
+ ```ts
360
+ import { html, signal, effect } from "balises";
361
+
362
+ class Counter extends HTMLElement {
363
+ #count = signal(0);
364
+ #dispose?: () => void;
365
+
366
+ connectedCallback() {
367
+ // Auto-sync to localStorage
368
+ const syncEffect = effect(() => {
369
+ localStorage.setItem("counter", String(this.#count.value));
370
+ });
371
+
372
+ const { fragment, dispose } = html`
373
+ <div>
374
+ <p>Count: ${this.#count}</p>
375
+ <button @click=${() => this.#count.update((n) => n - 1)}>-</button>
376
+ <button @click=${() => this.#count.update((n) => n + 1)}>+</button>
377
+ </div>
378
+ `.render();
379
+
380
+ this.appendChild(fragment);
381
+ this.#dispose = () => {
382
+ syncEffect();
383
+ dispose();
384
+ };
385
+ }
386
+
387
+ disconnectedCallback() {
388
+ this.#dispose?.();
389
+ }
390
+ }
391
+ ```
392
+
393
+ <!-- BENCHMARK_RESULTS_START -->
394
+
395
+ ## Benchmarks
396
+
397
+ Performance comparison of Balises against other popular reactive libraries. Benchmarks run in isolated processes to prevent V8 JIT contamination.
398
+
399
+ **Test Environment:**
400
+
401
+ - Node.js with V8 engine
402
+ - Each test runs in a separate process (isolated mode)
403
+ - **10 warmup runs** to stabilize JIT
404
+ - **100 iterations per test**, keeping middle 20 (discarding 40 best + 40 worst to reduce outliers)
405
+ - Tests measure pure reactive propagation (not DOM rendering)
406
+
407
+ **Scoring Methodology:**
408
+
409
+ - Overall ranking uses a combined score (50% average rank + 50% normalized average time)
410
+ - This ensures both consistency across scenarios (rank) and absolute performance (time) are valued equally
411
+ - "vs Fastest" compares average time to the fastest library
412
+
413
+ ### Overall Performance
414
+
415
+ ```
416
+ ┌───────┬───────────────────┬──────────┬───────────────┬──────────────────┐
417
+ │ Rank │ Library │ Avg Rank │ Avg Time (μs) │ vs Fastest │
418
+ ├───────┼───────────────────┼──────────┼───────────────┼──────────────────┤
419
+ │ #1 🏆 │ balises@0.2.1 │ 1.5 │ 69.76 │ 1.00x (baseline) │
420
+ ├───────┼───────────────────┼──────────┼───────────────┼──────────────────┤
421
+ │ #2 │ preact@1.12.1 │ 1.7 │ 51.59 │ 0.74x │
422
+ ├───────┼───────────────────┼──────────┼───────────────┼──────────────────┤
423
+ │ #3 │ vue@3.5.26 │ 3.0 │ 72.79 │ 1.04x │
424
+ ├───────┼───────────────────┼──────────┼───────────────┼──────────────────┤
425
+ │ #4 │ maverick@6.0.0 │ 3.8 │ 91.40 │ 1.31x │
426
+ ├───────┼───────────────────┼──────────┼───────────────┼──────────────────┤
427
+ │ #5 │ solid@1.9.10 │ 5.3 │ 225.26 │ 3.23x │
428
+ ├───────┼───────────────────┼──────────┼───────────────┼──────────────────┤
429
+ │ #6 │ mobx@6.15.0 │ 5.8 │ 719.17 │ 10.31x │
430
+ ├───────┼───────────────────┼──────────┼───────────────┼──────────────────┤
431
+ │ #7 │ hyperactiv@0.11.3 │ 6.8 │ 833.03 │ 11.94x │
432
+ └───────┴───────────────────┴──────────┴───────────────┴──────────────────┘
433
+ ```
434
+
435
+ ### Performance by Scenario
436
+
437
+ ```
438
+ ┌───────────────────┬───────────────┬─────────────┬────────────────┬────────────────────┬─────────────┬──────────────┬──────────┐
439
+ │ Library │ S1: 1: Layers │ S2: 2: Wide │ S3: 3: Diamond │ S4: 4: Conditional │ S5: 5: List │ S6: 6: Batch │ Avg Rank │
440
+ ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
441
+ │ balises@0.2.1 │ #3 │ #1 🏆 │ #1 🏆 │ #2 │ #1 🏆 │ #1 🏆 │ 1.5 │
442
+ ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
443
+ │ preact@1.12.1 │ #1 🏆 │ #2 │ #2 │ #1 🏆 │ #2 │ #2 │ 1.7 │
444
+ ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
445
+ │ vue@3.5.26 │ #2 │ #3 │ #3 │ #3 │ #3 │ #4 │ 3.0 │
446
+ ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
447
+ │ maverick@6.0.0 │ #4 │ #4 │ #4 │ #4 │ #4 │ #3 │ 3.8 │
448
+ ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
449
+ │ solid@1.9.10 │ #5 │ #6 │ #5 │ #5 │ #6 │ #5 │ 5.3 │
450
+ ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
451
+ │ mobx@6.15.0 │ #7 │ #5 │ #6 │ #6 │ #5 │ #6 │ 5.8 │
452
+ ├───────────────────┼───────────────┼─────────────┼────────────────┼────────────────────┼─────────────┼──────────────┼──────────┤
453
+ │ hyperactiv@0.11.3 │ #6 │ #7 │ #7 │ #7 │ #7 │ #7 │ 6.8 │
454
+ └───────────────────┴───────────────┴─────────────┴────────────────┴────────────────────┴─────────────┴──────────────┴──────────┘
455
+ ```
456
+
457
+ **Scenarios:**
458
+
459
+ - **S1: Layers** - Deep dependency chains (A→B→C→D...)
460
+ - **S2: Wide** - Many independent signals updating in parallel
461
+ - **S3: Diamond** - Multiple paths to same computed (diamond dependencies)
462
+ - **S4: Conditional** - Dynamic subscriptions (like v-if logic)
463
+ - **S5: List** - List operations with filtering (like v-for patterns)
464
+ - **S6: Batch** - Batched/transactional updates
465
+
466
+ **Interpretation:**
467
+
468
+ - Balises excels at diamond dependencies, list operations, and batching while maintaining competitive performance across all scenarios
469
+ - Results show pure reactivity performance - real-world apps should consider framework ecosystem, DX, and specific use cases
470
+ - Lower rank = better performance
471
+
472
+ _Last updated: 2025-12-30_
473
+
474
+ <!-- BENCHMARK_RESULTS_END -->
475
+
260
476
  ## Scripts
261
477
 
262
478
  ```bash
@@ -14,14 +14,14 @@ let batchQueue = null;
14
14
  */
15
15
  function batch(fn) {
16
16
  batchDepth++;
17
- if (batchDepth === 1) batchQueue = [];
17
+ if (batchDepth === 1) batchQueue = /* @__PURE__ */ new Set();
18
18
  try {
19
19
  return fn();
20
20
  } finally {
21
21
  if (--batchDepth === 0) {
22
22
  const q = batchQueue;
23
23
  batchQueue = null;
24
- for (let i = 0; i < q.length; i++) q[i]();
24
+ for (const sub of q) sub();
25
25
  }
26
26
  }
27
27
  }
@@ -31,11 +31,51 @@ function isBatching() {
31
31
  }
32
32
  /** Add a subscriber to the batch queue */
33
33
  function enqueueBatch(sub) {
34
- batchQueue.push(sub);
34
+ batchQueue.add(sub);
35
35
  }
36
36
  /** Add multiple subscribers to the batch queue */
37
37
  function enqueueBatchAll(subs) {
38
- batchQueue.push(...subs);
38
+ for (let i = 0; i < subs.length; i++) batchQueue.add(subs[i]);
39
+ }
40
+ /** Scope disposal: collect all disposers in a scope */
41
+ let disposalStack = null;
42
+ /**
43
+ * Create a disposal scope that collects all subscriptions and computeds created within.
44
+ * Returns the result of the function and a dispose function that cleans up all resources.
45
+ *
46
+ * @example
47
+ * ```ts
48
+ * const [result, dispose] = scope(() => {
49
+ * const count = signal(0);
50
+ * const doubled = computed(() => count.value * 2);
51
+ * effect(() => console.log(doubled.value));
52
+ * return { count, doubled };
53
+ * });
54
+ *
55
+ * // Later: clean up all subscriptions and computeds
56
+ * dispose();
57
+ * ```
58
+ */
59
+ function scope(fn) {
60
+ const disposers = [];
61
+ if (!disposalStack) disposalStack = [];
62
+ disposalStack.push(disposers);
63
+ try {
64
+ return [fn(), () => {
65
+ for (let i = disposers.length - 1; i >= 0; i--) disposers[i]();
66
+ disposers.length = 0;
67
+ }];
68
+ } finally {
69
+ disposalStack.pop();
70
+ if (disposalStack.length === 0) disposalStack = null;
71
+ }
72
+ }
73
+ /**
74
+ * Register a disposer in the current scope (if any).
75
+ * This is called internally by computed/effect when they create cleanup functions.
76
+ */
77
+ function registerDisposer(dispose) {
78
+ if (disposalStack && disposalStack.length > 0) disposalStack[disposalStack.length - 1].push(dispose);
39
79
  }
40
80
 
41
81
  //#endregion
@@ -53,6 +93,8 @@ function removeFromArray(array, item) {
53
93
  /**
54
94
  * A reactive value container. When the value changes, all dependent
55
95
  * computeds are marked dirty and subscribers are notified.
96
+ *
97
+ * Uses Object.is() for equality checks to correctly handle NaN values.
56
98
  */
57
99
  var Signal = class {
58
100
  #value;
@@ -66,7 +108,7 @@ var Signal = class {
66
108
  return this.#value;
67
109
  }
68
110
  set value(v) {
69
- if (this.#value === v) return;
111
+ if (Object.is(this.#value, v)) return;
70
112
  this.#value = v;
71
113
  const targets = this.#targets;
72
114
  for (let i = 0; i < targets.length; i++) targets[i].markDirty();
@@ -77,6 +119,18 @@ var Signal = class {
77
119
  this.#subs.push(fn);
78
120
  return () => removeFromArray(this.#subs, fn);
79
121
  }
122
+ /**
123
+ * Update the signal value using an updater function.
124
+ *
125
+ * @param fn - Function that receives current value and returns new value
126
+ *
127
+ * @example
128
+ * const count = signal(0);
129
+ * count.update(n => n + 1); // increment
130
+ */
131
+ update(fn) {
132
+ this.value = fn(this.#value);
133
+ }
80
134
  /** @internal */
81
135
  get targets() {
82
136
  return this.#targets;
@@ -97,6 +151,8 @@ const signal = (value) => new Signal(value);
97
151
  /**
98
152
  * A derived reactive value. Automatically tracks dependencies and
99
153
  * recomputes when any dependency changes.
154
+ *
155
+ * Uses Object.is() for equality checks to correctly handle NaN values.
100
156
  */
101
157
  var Computed = class {
102
158
  #fn;
@@ -110,6 +166,7 @@ var Computed = class {
110
166
  constructor(fn) {
111
167
  this.#fn = fn;
112
168
  this.#recompute();
169
+ registerDisposer(() => this.dispose());
113
170
  }
114
171
  get value() {
115
172
  if (this.#dirty) this.#recompute();
@@ -123,7 +180,10 @@ var Computed = class {
123
180
  dispose() {
124
181
  this.#fn = void 0;
125
182
  const sources = this.#sources;
126
- for (let i = 0; i < sources.length; i++) sources[i].deleteTarget(this);
183
+ for (let i = 0; i < sources.length; i++) {
184
+ const source = sources[i];
185
+ if (source) source.deleteTarget(this);
186
+ }
127
187
  this.#sources = [];
128
188
  this.#subs.length = 0;
129
189
  }
@@ -136,7 +196,10 @@ var Computed = class {
136
196
  const idx = this.#sourceIndex++;
137
197
  if (idx < sources.length) {
138
198
  if (sources[idx] === source) return;
139
- for (let i = idx; i < sources.length; i++) sources[i].deleteTarget(this);
199
+ for (let i = idx; i < sources.length; i++) {
200
+ const source$1 = sources[i];
201
+ if (source$1) source$1.deleteTarget(this);
202
+ }
140
203
  sources.length = idx;
141
204
  }
142
205
  sources.push(source);
@@ -154,20 +217,32 @@ var Computed = class {
154
217
  if (c.#dirty) continue;
155
218
  c.#dirty = true;
156
219
  const targets = c.#targets;
157
- for (let j = 0; j < targets.length; j++) {
158
- const target = targets[j];
159
- if (!target.#dirty) queue.push(target);
160
- }
161
- if (c.#subs.length) {
220
+ if (c.#subs.length > 0 && targets.length > 0 && c.#fn && !isBatching()) {
162
221
  const old = c.#value;
163
- const notify = () => {
164
- if (c.#fn) {
165
- c.#recompute();
166
- if (c.#value !== old) for (let j = 0; j < c.#subs.length; j++) c.#subs[j]();
222
+ c.#recompute();
223
+ if (!Object.is(c.#value, old)) {
224
+ for (let j = 0; j < targets.length; j++) {
225
+ const target = targets[j];
226
+ if (!target.#dirty) queue.push(target);
167
227
  }
168
- };
169
- if (isBatching()) enqueueBatch(notify);
170
- else notify();
228
+ for (let j = 0; j < c.#subs.length; j++) c.#subs[j]();
229
+ }
230
+ } else {
231
+ for (let j = 0; j < targets.length; j++) {
232
+ const target = targets[j];
233
+ if (!target.#dirty) queue.push(target);
234
+ }
235
+ if (c.#subs.length) {
236
+ const old = c.#value;
237
+ const notify = () => {
238
+ if (c.#fn) {
239
+ c.#recompute();
240
+ if (!Object.is(c.#value, old)) for (let j = 0; j < c.#subs.length; j++) c.#subs[j]();
241
+ }
242
+ };
243
+ if (isBatching()) enqueueBatch(notify);
244
+ else notify();
245
+ }
171
246
  }
172
247
  }
173
248
  }
@@ -193,7 +268,10 @@ var Computed = class {
193
268
  const newLen = this.#sourceIndex;
194
269
  if (newLen < prevLen) {
195
270
  const sources = this.#sources;
196
- for (let i = newLen; i < prevLen; i++) sources[i].deleteTarget(this);
271
+ for (let i = newLen; i < prevLen; i++) {
272
+ const source = sources[i];
273
+ if (source) source.deleteTarget(this);
274
+ }
197
275
  sources.length = newLen;
198
276
  }
199
277
  this.#dirty = false;
@@ -204,6 +282,40 @@ var Computed = class {
204
282
  /** Create a new computed from the given function. */
205
283
  const computed = (fn) => new Computed(fn);
206
284
 
285
+ //#endregion
286
+ //#region src/signals/effect.ts
287
+ /**
288
+ * Effect - Run side effects reactively.
289
+ */
290
+ /**
291
+ * Create a reactive effect that automatically tracks dependencies
292
+ * and re-runs when they change.
293
+ *
294
+ * @param fn - The effect function to run
295
+ * @returns A dispose function to stop the effect
296
+ *
297
+ * @example
298
+ * const count = signal(0);
299
+ * const dispose = effect(() => {
300
+ * console.log("Count is:", count.value);
301
+ * });
302
+ *
303
+ * count.value = 1; // logs: "Count is: 1"
304
+ * dispose(); // stop the effect
305
+ */
306
+ function effect(fn) {
307
+ const c = computed(() => {
308
+ fn();
309
+ });
310
+ const unsub = c.subscribe(() => {});
311
+ const dispose = () => {
312
+ unsub();
313
+ c.dispose();
314
+ };
315
+ registerDisposer(dispose);
316
+ return dispose;
317
+ }
318
+
207
319
  //#endregion
208
320
  //#region src/signals/store.ts
209
321
  /**
@@ -648,5 +760,5 @@ function each(list, keyFnOrRenderFn, renderFn) {
648
760
  }
649
761
 
650
762
  //#endregion
651
- export { Computed, Signal, Template, batch, computed, each, html, isSignal, signal, store };
763
+ export { Computed, Signal, Template, batch, computed, each, effect, html, isSignal, scope, signal, store };
652
764
  //# sourceMappingURL=balises.esm.js.map