balises 0.7.2 → 0.8.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.
@@ -97,6 +97,29 @@ function removeFromArray(array, item) {
97
97
  }
98
98
  }
99
99
  /**
100
+ * A tracking slot for .is() comparisons.
101
+ * Self-removes from parent map when all targets are gone.
102
+ * @internal
103
+ */
104
+ var IsSlot = class {
105
+ targets = [];
106
+ #map;
107
+ #key;
108
+ constructor(map, key) {
109
+ this.#map = map;
110
+ this.#key = key;
111
+ }
112
+ deleteTarget(target) {
113
+ removeFromArray(this.targets, target);
114
+ if (!this.targets.length) this.#map.delete(this.#key);
115
+ }
116
+ /** Notify all targets that they may need to recompute */
117
+ notify() {
118
+ const copy = this.targets.slice();
119
+ for (let i = 0; i < copy.length; i++) copy[i].markDirty();
120
+ }
121
+ };
122
+ /**
100
123
  * A reactive value container. When the value changes, all dependent
101
124
  * computeds are marked dirty and subscribers are notified.
102
125
  *
@@ -106,6 +129,7 @@ var Signal = class {
106
129
  #value;
107
130
  #subs = [];
108
131
  #targets = [];
132
+ #isSlots;
109
133
  constructor(value) {
110
134
  this.#value = value;
111
135
  }
@@ -116,12 +140,19 @@ var Signal = class {
116
140
  }
117
141
  set value(v) {
118
142
  if (Object.is(this.#value, v)) return;
143
+ const prev = this.#value;
119
144
  this.#value = v;
120
145
  const targets = this.#targets;
121
- for (let i = 0; i < targets.length; i++) targets[i].markDirty();
146
+ const isSlots = this.#isSlots;
147
+ if (!isSlots) for (let i = 0; i < targets.length; i++) targets[i].markDirty();
148
+ else batch(() => {
149
+ isSlots.get(prev)?.notify();
150
+ isSlots.get(v)?.notify();
151
+ for (let i = 0; i < targets.length; i++) targets[i].markDirty();
152
+ });
122
153
  if (this.#subs.length) if (isBatching()) enqueueBatchAll(this.#subs);
123
154
  else {
124
- const subs = [...this.#subs];
155
+ const subs = this.#subs.slice();
125
156
  for (let i = 0; i < subs.length; i++) subs[i]();
126
157
  }
127
158
  }
@@ -154,6 +185,28 @@ var Signal = class {
154
185
  peek() {
155
186
  return this.#value;
156
187
  }
188
+ /**
189
+ * Check if the signal's value equals the given value.
190
+ * Enables O(1) selection updates - only the old and new matching values
191
+ * trigger recomputes, not all dependents.
192
+ *
193
+ * @example
194
+ * ```ts
195
+ * const selected = signal<number | null>(null);
196
+ *
197
+ * // In each row - only 2 rows recompute when selection changes
198
+ * html`<tr class=${() => selected.is(row.id) ? 'danger' : ''}>...`
199
+ * ```
200
+ */
201
+ is(value) {
202
+ if (context) {
203
+ const slots = this.#isSlots ?? (this.#isSlots = /* @__PURE__ */ new Map());
204
+ let slot = slots.get(value);
205
+ if (!slot) slots.set(value, slot = new IsSlot(slots, value));
206
+ context.trackSource(slot);
207
+ }
208
+ return Object.is(this.#value, value);
209
+ }
157
210
  /** @internal */
158
211
  get targets() {
159
212
  return this.#targets;
@@ -190,6 +243,13 @@ var ReadonlySignal = class {
190
243
  subscribe(fn) {
191
244
  return this.#signal.subscribe(fn);
192
245
  }
246
+ /**
247
+ * Check if the signal's value equals the given value.
248
+ * Enables O(1) selection updates.
249
+ */
250
+ is(value) {
251
+ return this.#signal.is(value);
252
+ }
193
253
  };
194
254
 
195
255
  //#endregion
@@ -212,6 +272,7 @@ var Computed = class {
212
272
  #targets = [];
213
273
  #sources = [];
214
274
  #sourceIndex = 0;
275
+ #isSlots;
215
276
  constructor(fn) {
216
277
  this.#fn = fn;
217
278
  this.#recompute();
@@ -230,12 +291,33 @@ var Computed = class {
230
291
  dispose() {
231
292
  this.#fn = void 0;
232
293
  const sources = this.#sources;
233
- for (let i = 0; i < sources.length; i++) {
234
- const source = sources[i];
235
- if (source) source.deleteTarget(this);
236
- }
294
+ for (let i = 0; i < sources.length; i++) sources[i]?.deleteTarget(this);
237
295
  this.#sources = [];
238
296
  this.#subs.length = 0;
297
+ this.#isSlots?.clear();
298
+ }
299
+ /**
300
+ * Check if the computed's value equals the given value.
301
+ * Enables O(1) selection updates - only the old and new matching values
302
+ * trigger recomputes, not all dependents.
303
+ *
304
+ * @example
305
+ * ```ts
306
+ * const selected = computed(() => items.find(i => i.active)?.id ?? null);
307
+ *
308
+ * // In each row - only 2 rows recompute when selection changes
309
+ * html`<tr class=${() => selected.is(row.id) ? 'danger' : ''}>...`
310
+ * ```
311
+ */
312
+ is(value) {
313
+ if (this.#dirty) this.#recompute();
314
+ if (context) {
315
+ const slots = this.#isSlots ?? (this.#isSlots = /* @__PURE__ */ new Map());
316
+ let slot = slots.get(value);
317
+ if (!slot) slots.set(value, slot = new IsSlot(slots, value));
318
+ context.trackSource(slot);
319
+ }
320
+ return Object.is(this.#value, value);
239
321
  }
240
322
  /**
241
323
  * Called by sources when accessed during recompute.
@@ -276,20 +358,20 @@ var Computed = class {
276
358
  const t = targets[j];
277
359
  if (!t.#dirty) queue.push(t);
278
360
  }
279
- if (c.#subs.length && c.#fn) toNotify.push({
280
- c,
281
- old: c.#value
282
- });
361
+ if ((c.#subs.length || c.#isSlots?.size) && c.#fn) toNotify.push([c, c.#value]);
283
362
  }
284
363
  for (let i = 0; i < toNotify.length; i++) {
285
- const { c, old } = toNotify[i];
364
+ const [c, old] = toNotify[i];
286
365
  const notify = () => {
287
- if (c.#fn) {
288
- c.#recompute();
289
- if (!Object.is(c.#value, old)) {
290
- const subs = c.#subs;
291
- for (let j = 0; j < subs.length; j++) subs[j]();
366
+ if (!c.#fn) return;
367
+ c.#recompute();
368
+ if (!Object.is(c.#value, old)) {
369
+ if (c.#isSlots) {
370
+ c.#isSlots.get(old)?.notify();
371
+ c.#isSlots.get(c.#value)?.notify();
292
372
  }
373
+ const subs = c.#subs.slice();
374
+ for (let j = 0; j < subs.length; j++) subs[j]();
293
375
  }
294
376
  };
295
377
  isBatching() ? enqueueBatchOne(notify) : notify();
@@ -317,10 +399,7 @@ var Computed = class {
317
399
  const newLen = this.#sourceIndex;
318
400
  if (newLen < prevLen) {
319
401
  const sources = this.#sources;
320
- for (let i = newLen; i < prevLen; i++) {
321
- const source = sources[i];
322
- if (source) source.deleteTarget(this);
323
- }
402
+ for (let i = newLen; i < prevLen; i++) sources[i]?.deleteTarget(this);
324
403
  sources.length = newLen;
325
404
  }
326
405
  this.#dirty = false;
@@ -588,15 +667,9 @@ var HTMLParser = class {
588
667
  this.statics = [];
589
668
  this.indexes = [];
590
669
  }
591
- isA(c) {
592
- return c >= "a" && c <= "z" || c >= "A" && c <= "Z";
593
- }
594
- isT(c) {
595
- return c >= "a" && c <= "z" || c >= "A" && c <= "Z" || c >= "0" && c <= "9" || c === "-" || c === ":";
596
- }
597
- isW(c) {
598
- return c <= " " && c !== "";
599
- }
670
+ isA = (c) => c >= "a" && c <= "z" || c >= "A" && c <= "Z";
671
+ isT = (c) => c >= "a" && c <= "z" || c >= "A" && c <= "Z" || c >= "0" && c <= "9" || c === "-" || c === ":";
672
+ isW = (c) => c <= " " && c !== "";
600
673
  };
601
674
 
602
675
  //#endregion
@@ -613,207 +686,237 @@ var HTMLParser = class {
613
686
  * - Arrays: ${items.map(i => html`<li>${i}</li>`)}
614
687
  *
615
688
  * Extend with plugins via html.with(...plugins) for additional interpolation types.
689
+ *
690
+ * Templates are cached by their static string parts - the DOM structure is built
691
+ * once and cloned for subsequent renders, significantly improving performance.
616
692
  */
617
693
  const SVG_NS = "http://www.w3.org/2000/svg";
694
+ /** Template cache - keyed by static string parts identity */
695
+ const cache = /* @__PURE__ */ new WeakMap();
618
696
  /**
619
- * Create a computed that wraps function execution in a scope.
697
+ * Wrap a function in a scoped computed.
620
698
  * Nested computeds/effects are automatically disposed on re-run.
621
- * Returns [computed, dispose] - dispose cleans up both the computed
622
- * and any nested reactives from the last run.
699
+ * Registers disposal of both the computed and nested reactives.
623
700
  */
624
- function scopedComputed(fn) {
625
- let disposeScope;
701
+ function wrapFn(fn, d) {
702
+ let cleanup;
626
703
  const c = computed(() => {
627
- disposeScope?.();
628
- const [result, dispose] = scope(fn);
629
- disposeScope = dispose;
630
- return result;
704
+ cleanup?.();
705
+ const [r, dispose] = scope(fn);
706
+ cleanup = dispose;
707
+ return r;
631
708
  });
632
- return [c, () => (c.dispose(), disposeScope?.())];
709
+ d.push(() => (c.dispose(), cleanup?.()));
710
+ return c;
633
711
  }
634
712
  /**
635
713
  * Bind a value to an update function.
636
- * If reactive, subscribes and returns unsubscribe. Otherwise returns null.
637
714
  * Functions are wrapped in computed() for automatic reactivity.
638
715
  * Nested computeds/effects created inside functions are automatically
639
716
  * disposed when the function re-runs or the binding is disposed.
640
717
  */
641
- function bind(value, update) {
642
- let dispose;
643
- if (typeof value === "function") [value, dispose] = scopedComputed(value);
644
- if (isSignal(value)) {
645
- update(value.value);
646
- const unsub = value.subscribe(() => update(value.value));
647
- return dispose ? () => (unsub(), dispose()) : unsub;
648
- }
649
- update(value);
718
+ function bind(v, update, d) {
719
+ if (typeof v === "function") v = wrapFn(v, d);
720
+ if (isSignal(v)) {
721
+ update(v.value);
722
+ d.push(v.subscribe(() => update(v.value)));
723
+ } else update(v);
724
+ }
725
+ /**
726
+ * Collect nodes for all bindings using a single TreeWalker pass.
727
+ * TreeWalker with filter 129 (SHOW_ELEMENT | SHOW_COMMENT) visits nodes
728
+ * in the same order they were created, matching our nodeIndex counter.
729
+ * Bindings are in document order but may share nodes (multiple attrs).
730
+ */
731
+ function collectBindingNodes(frag, bindings) {
732
+ if (!bindings.length) return [];
733
+ const result = new Array(bindings.length);
734
+ const walker = document.createTreeWalker(frag, 129);
735
+ let nodeIndex = -1;
736
+ let node = null;
737
+ for (let i = 0; i < bindings.length; i++) {
738
+ const targetIndex = bindings[i][1];
739
+ while (nodeIndex < targetIndex) {
740
+ node = walker.nextNode();
741
+ nodeIndex++;
742
+ }
743
+ result[i] = node;
744
+ }
745
+ return result;
650
746
  }
651
747
  /** A parsed HTML template. Call render() to create live DOM. */
652
- var Template = class {
748
+ var Template = class Template {
749
+ #strings;
750
+ #values;
751
+ #plugins;
653
752
  constructor(strings, values, plugins = []) {
654
- this.strings = strings;
655
- this.values = values;
656
- this.plugins = plugins;
753
+ this.#strings = strings;
754
+ this.#values = values;
755
+ this.#plugins = plugins;
657
756
  }
658
757
  /**
659
758
  * Parse template and create live DOM.
660
759
  * Returns the fragment and a dispose function to clean up subscriptions.
760
+ *
761
+ * Templates are cached by their static string parts - subsequent renders
762
+ * clone the cached DOM structure instead of rebuilding it.
661
763
  */
662
764
  render() {
663
- const fragment = document.createDocumentFragment();
664
- const disposers = [];
665
- const stack = [fragment];
666
- const parser = new HTMLParser();
667
- const values = this.values;
668
- const plugins = this.plugins;
669
- const handleAttribute = (el, [name, statics, indexes]) => {
670
- const idx0 = indexes[0];
671
- if (name[0] === "@") {
672
- if (idx0 != null) {
673
- const handler = values[idx0];
674
- el.addEventListener(name.slice(1), handler);
675
- disposers.push(() => el.removeEventListener(name.slice(1), handler));
676
- }
677
- return;
678
- }
679
- if (name[0] === ".") {
680
- if (idx0 != null) {
681
- const unsub = bind(values[idx0], (v) => {
682
- el[name.slice(1)] = v;
683
- });
684
- if (unsub) disposers.push(unsub);
685
- }
686
- return;
687
- }
688
- if (!indexes.length) {
689
- const value = statics[0] ?? "";
690
- el.setAttribute(name, value);
691
- return;
692
- }
693
- const reactives = [];
694
- for (const idx of indexes) {
695
- const v = values[idx];
696
- if (typeof v === "function") {
697
- const [c, dispose] = scopedComputed(v);
698
- values[idx] = c;
699
- reactives.push(c);
700
- disposers.push(dispose);
701
- } else if (isSignal(v)) reactives.push(v);
702
- }
703
- const update = () => {
704
- let result = statics[0];
705
- for (let i = 0; i < indexes.length; i++) {
706
- const v = values[indexes[i]];
707
- const val = isSignal(v) ? v.value : v;
708
- if (indexes.length === 1 && (val == null || val === false)) {
709
- el.removeAttribute(name);
710
- return;
711
- }
712
- result += (val === true ? "" : val ?? "") + statics[i + 1];
713
- }
714
- el.setAttribute(name, result);
715
- };
716
- update();
717
- for (const s of reactives) disposers.push(s.subscribe(update));
718
- };
719
- /**
720
- * Bind dynamic content at a marker position.
721
- * Tries plugins first (first match wins), then falls back to default handling.
722
- */
723
- const bindContent = (marker, value) => {
724
- for (const plugin of plugins) {
725
- const binder = plugin(value);
726
- if (binder) {
727
- const pluginDisposers = [];
728
- binder(marker, pluginDisposers);
729
- return () => pluginDisposers.forEach((d) => d());
730
- }
731
- }
732
- let nodes = [];
733
- let childDisposers = [];
734
- const clear = () => {
735
- childDisposers.forEach((d) => d());
736
- childDisposers = [];
737
- nodes.forEach((n) => n.remove());
738
- nodes = [];
739
- };
740
- const update = (v) => {
741
- clear();
742
- renderContent(marker, v, nodes, childDisposers);
743
- };
744
- const unsub = bind(value, update);
745
- return () => {
746
- unsub?.();
747
- clear();
748
- };
749
- };
750
- parser.parseTemplate(this.strings, {
751
- onText: (text) => {
752
- stack.at(-1).append(text);
753
- },
754
- onOpenTag: (tag, attrs, selfClosing) => {
755
- const parent = stack.at(-1);
765
+ let cached = cache.get(this.#strings);
766
+ if (!cached) cache.set(this.#strings, cached = this.#buildPrototype());
767
+ return this.#instantiate(cached);
768
+ }
769
+ /** Build the prototype fragment and collect binding descriptors */
770
+ #buildPrototype() {
771
+ const frag = document.createDocumentFragment();
772
+ const bindings = [];
773
+ const stack = [frag];
774
+ let nodeIndex = 0;
775
+ new HTMLParser().parseTemplate(this.#strings, {
776
+ onText: (t) => stack[stack.length - 1].append(t),
777
+ onOpenTag: (tag, attrs, selfClose) => {
778
+ const parent = stack[stack.length - 1];
756
779
  const el = tag === "svg" || tag === "SVG" || parent instanceof Element && parent.namespaceURI === SVG_NS ? document.createElementNS(SVG_NS, tag) : document.createElement(tag);
757
- for (const attr of attrs) handleAttribute(el, attr);
780
+ const elIndex = nodeIndex++;
781
+ for (const [name, statics, slots] of attrs) if (!slots.length) el.setAttribute(name, statics[0] ?? "");
782
+ else {
783
+ const c = name[0];
784
+ if (c === "@") bindings.push([
785
+ 3,
786
+ elIndex,
787
+ name.slice(1),
788
+ slots[0]
789
+ ]);
790
+ else if (c === ".") bindings.push([
791
+ 2,
792
+ elIndex,
793
+ name.slice(1),
794
+ slots[0]
795
+ ]);
796
+ else bindings.push([
797
+ 1,
798
+ elIndex,
799
+ name,
800
+ statics,
801
+ slots
802
+ ]);
803
+ }
758
804
  parent.appendChild(el);
759
- if (!selfClosing) stack.push(el);
805
+ if (!selfClose) stack.push(el);
760
806
  },
761
807
  onClose: () => {
762
808
  if (stack.length > 1) stack.pop();
763
809
  },
764
- onSlot: (index) => {
765
- const marker = document.createComment("");
766
- stack.at(-1).appendChild(marker);
767
- disposers.push(bindContent(marker, values[index]));
810
+ onSlot: (i) => {
811
+ stack[stack.length - 1].appendChild(document.createComment(""));
812
+ bindings.push([
813
+ 0,
814
+ nodeIndex++,
815
+ i
816
+ ]);
768
817
  }
769
818
  });
819
+ return [frag, bindings];
820
+ }
821
+ /** Clone the prototype and apply bindings with current values */
822
+ #instantiate([proto, bindings]) {
823
+ const frag = proto.cloneNode(true);
824
+ const disposers = [];
825
+ const values = this.#values;
826
+ const nodes = collectBindingNodes(frag, bindings);
827
+ for (let i = 0; i < bindings.length; i++) {
828
+ const b = bindings[i];
829
+ const node = nodes[i];
830
+ if (b[0] === 0) {
831
+ const value = values[b[2]];
832
+ const t = typeof value;
833
+ if (t === "string" || t === "number" || t === "bigint") {
834
+ const n = document.createTextNode(String(value));
835
+ node.parentNode.insertBefore(n, node);
836
+ } else if (value == null || t === "boolean") {} else this.#bindContent(node, value, disposers);
837
+ } else if (b[0] === 1) {
838
+ const [, , name, statics, slots] = b;
839
+ const resolved = slots.map((s) => {
840
+ const v = values[s];
841
+ return typeof v === "function" ? wrapFn(v, disposers) : v;
842
+ });
843
+ let prev;
844
+ const update = () => {
845
+ let result = statics[0], allNull = true;
846
+ for (let j = 0; j < resolved.length; j++) {
847
+ const val = isSignal(resolved[j]) ? resolved[j].value : resolved[j];
848
+ if (val != null && val !== false) allNull = false;
849
+ result += (val === true ? "" : val ?? "") + statics[j + 1];
850
+ }
851
+ const next = slots.length === 1 && allNull ? null : result;
852
+ if (next !== prev) {
853
+ prev = next;
854
+ if (next === null) node.removeAttribute(name);
855
+ else node.setAttribute(name, next);
856
+ }
857
+ };
858
+ update();
859
+ for (const r of resolved) if (isSignal(r)) disposers.push(r.subscribe(update));
860
+ } else if (b[0] === 2) {
861
+ const [, , name, slot] = b;
862
+ bind(values[slot], (v) => node[name] = v, disposers);
863
+ } else {
864
+ const [, , name, slot] = b;
865
+ const handler = values[slot];
866
+ node.addEventListener(name, handler);
867
+ disposers.push(() => node.removeEventListener(name, handler));
868
+ }
869
+ }
770
870
  return {
771
- fragment,
772
- dispose: () => disposers.forEach((d) => d())
871
+ fragment: frag,
872
+ dispose: () => disposers.forEach((f) => f())
773
873
  };
774
874
  }
775
- };
776
- /**
777
- * Render content and insert nodes before marker.
778
- * Handles Templates, primitives, arrays.
779
- */
780
- function renderContent(marker, v, nodes, childDisposers) {
781
- const parent = marker.parentNode;
782
- for (const item of Array.isArray(v) ? v : [v]) if (item instanceof Template) {
783
- const { fragment, dispose } = item.render();
784
- childDisposers.push(dispose);
785
- nodes.push(...fragment.childNodes);
786
- parent.insertBefore(fragment, marker);
787
- } else if (item != null && typeof item !== "boolean") {
788
- const node = document.createTextNode(String(item));
789
- nodes.push(node);
790
- parent.insertBefore(node, marker);
875
+ /** Bind content slot - handles plugins, templates, arrays, and reactive values */
876
+ #bindContent(marker, value, disposers) {
877
+ for (const plugin of this.#plugins) {
878
+ const binder = plugin(value);
879
+ if (binder) {
880
+ binder(marker, disposers);
881
+ return;
882
+ }
883
+ }
884
+ let currentNodes = [], childDisposers = [];
885
+ const clear = () => {
886
+ childDisposers.forEach((f) => f());
887
+ childDisposers = [];
888
+ currentNodes.forEach((n) => n.remove());
889
+ currentNodes = [];
890
+ };
891
+ const update = (v) => {
892
+ if (v != null && typeof v !== "boolean" && typeof v !== "object" && currentNodes.length === 1 && !childDisposers.length && currentNodes[0] instanceof Text) {
893
+ currentNodes[0].textContent = String(v);
894
+ return;
895
+ }
896
+ clear();
897
+ const parent = marker.parentNode;
898
+ const items = Array.isArray(v) ? v.flat() : [v];
899
+ for (const item of items) if (item instanceof Template) {
900
+ const { fragment, dispose } = item.render();
901
+ childDisposers.push(dispose);
902
+ currentNodes.push(...fragment.childNodes);
903
+ parent.insertBefore(fragment, marker);
904
+ } else if (item != null && typeof item !== "boolean") {
905
+ const n = document.createTextNode(String(item));
906
+ currentNodes.push(n);
907
+ parent.insertBefore(n, marker);
908
+ }
909
+ };
910
+ bind(value, update, disposers);
911
+ disposers.push(clear);
791
912
  }
792
- }
793
- /**
794
- * Create an html tag function with the given plugins.
795
- */
796
- function createHtmlWithPlugins(plugins) {
913
+ };
914
+ function createHtml(plugins) {
797
915
  const tag = ((strings, ...values) => new Template(strings, values, plugins));
798
- tag.with = (...morePlugins) => createHtmlWithPlugins([...plugins, ...morePlugins]);
916
+ tag.with = (...more) => createHtml([...plugins, ...more]);
799
917
  return tag;
800
918
  }
801
- /**
802
- * Tagged template literal for creating reactive HTML templates.
803
- * Use .with(...plugins) to add interpolation handlers like each() or async generators.
804
- *
805
- * @example
806
- * ```ts
807
- * import { html } from "balises";
808
- * import eachPlugin, { each } from "balises/each";
809
- * import asyncPlugin from "balises/async";
810
- *
811
- * const html = baseHtml.with(eachPlugin, asyncPlugin);
812
- *
813
- * html`<div>${async function* () { ... }}</div>`.render();
814
- * ```
815
- */
816
- const html = createHtmlWithPlugins([]);
919
+ const html = createHtml([]);
817
920
 
818
921
  //#endregion
819
922
  export { batch, computed, effect, html, scope, signal, store };