solarite 0.7.1 → 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.
@@ -1,6 +1,6 @@
1
1
  /*@__NO_SIDE_EFFECTS__*/
2
2
  function assert(val) {
3
- //#IFDEV
3
+ //#IFDEBUG
4
4
  if (!val) {
5
5
  //debugger;
6
6
  throw new Error('Assertion failed: ' + val);
@@ -160,13 +160,15 @@ let Util = {
160
160
  // Don't clobber a non-element value. For a simple (non-nested) id this covers two cases:
161
161
  // an inherited/built-in property like `title` or `style`, or an own property that already
162
162
  // holds a non-Node value. A previously-bound element (a Node) is fine to re-assign.
163
+ // This can only fail on a mistake in the component's own template, so a developer meets it
164
+ // the first time the component renders and never again at runtime. It nonetheless SHIPS,
165
+ // and deliberately: debug-strip blocks are removed from dist/Solarite.js, which is what
166
+ // npm serves, so hiding it there would delete it for everyone, not only for production.
163
167
  if (!id.includes('.')) {
164
168
  let existing = root[id];
165
169
  let isInherited = (id in root) && !Object.hasOwn(root, id);
166
170
  if (!existing?.nodeType && (existing != null || isInherited))
167
- throw new Error(`${root.constructor.name}.${id} can't be a reference to ` +
168
- `<${el.tagName.toLowerCase()} id="${id}"> because it would clobber an existing ` +
169
- `${isInherited ? 'built-in ' : ''}property. Rename the id or the property.`);
171
+ throw new Error(`Solarite: id="${id}" would overwrite an existing ${root.constructor.name} property.`);
170
172
  }
171
173
 
172
174
  delve(root, id.split(/\./g), el);
@@ -185,29 +187,29 @@ let Util = {
185
187
  bindStyles(style, root) {
186
188
 
187
189
  let tagName = root.tagName.toLowerCase();
188
- let styleId, attribSelector;
190
+
191
+ // A global style is scoped by tag name alone, so it needs no attribute in the selector.
192
+ let attribSelector = '';
189
193
 
190
194
  if (style.hasAttribute('global') || style.hasAttribute('data-global')) {
191
- styleId = tagName;
192
- attribSelector = '';
193
- let doc = Globals$1.doc || root.ownerDocument || document;
194
- if (!doc.head.querySelector(`style[data-style="${styleId}"]`)) {
195
- doc.head.append(style);
196
- style.setAttribute('data-style', styleId);
197
- }
198
- else // TODO: Make sure the style has no expressions.
195
+ let head = Globals$1.doc.head;
196
+ if (head.querySelector(`style[data-style="${tagName}"]`))
197
+ // TODO: Make sure the style has no expressions.
199
198
  style.remove(); // already in the head.
199
+ else {
200
+ head.append(style);
201
+ style.setAttribute('data-style', tagName);
202
+ }
200
203
  }
201
204
  else {
202
205
  let styleId = root.getAttribute('data-style');
203
206
  if (!styleId) {
204
- // Keep track of one style id for each class.
207
+ // Keep track of one style id for each class. Reading the static walks up to a parent
208
+ // class's counter if this class has never been styled, but the assignment always lands
209
+ // on this class, so each class then counts on from where its parent left off.
205
210
  // TODO: Put this outside the class in a map, so it doesn't conflict with static properties.
206
- if (!root.constructor.styleId)
207
- root.constructor.styleId = 1;
208
- styleId = root.constructor.styleId++;
209
-
210
- root.setAttribute('data-style', styleId);
211
+ let Class = root.constructor;
212
+ root.setAttribute('data-style', styleId = Class.styleId = (Class.styleId || 0) + 1);
211
213
  }
212
214
 
213
215
  attribSelector = `[data-style="${styleId}"]`;
@@ -217,7 +219,19 @@ let Util = {
217
219
  for (let child of style.childNodes) {
218
220
  if (child.nodeType === 3) {
219
221
  let oldText = child.textContent;
220
- let newText = oldText.replace(/:host(?=[^a-z0-9_])/gi, `${tagName}${attribSelector}`);
222
+
223
+ // One pass rewrites both forms of the selector:
224
+ // 1. The functional form ':host(X)' — the host element when it also matches X — unwraps
225
+ // so X sits right after the scoped name: tag[data-style="1"]X. X may hold one
226
+ // nested group like ':not(.open)'; deeper parentheses can't be paired by a regex,
227
+ // so such an X is left as written rather than half-rewritten into a selector the
228
+ // browser would discard silently.
229
+ // 2. Plain ':host'. The lookahead turns down longer names (':host-context') and '(',
230
+ // which only follows ':host' when alternative 1 already gave up on it, and accepts
231
+ // the end of the text node, where an expression may have split a dynamic style.
232
+ let newText = oldText.replace(
233
+ /:host(?:\(((?:[^()]|\([^()]*\))*)\)|(?![-a-z0-9_(]))/gi,
234
+ `${tagName}${attribSelector}$1`);
221
235
  if (oldText !== newText)
222
236
  child.textContent = newText;
223
237
  }
@@ -238,17 +252,15 @@ let Util = {
238
252
  * 'UIForm' => 'ui-form'
239
253
  * 'A100' => 'a-100' */
240
254
  camelToDashes(str) {
241
- // Convert any capital letter that is preceded by a lowercase letter or number to lowercase and precede with a dash.
242
- str = str.replace(/([a-z0-9])([A-Z])/g, '$1-$2');
243
-
244
- // Convert any capital letter that is followed by a lowercase letter or number to lowercase and precede with a dash.
245
- str = str.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2');
246
-
247
- // Convert any number that is preceded by a lowercase or uppercase letter to be preceded by a dash.
248
- str = str.replace(/([a-zA-Z])([0-9])/g, '$1-$2');
249
-
250
- // Convert all the remaining capital letters to lowercase.
251
- return str.toLowerCase();
255
+ // One pass finds all three dash positions. Each alternative matches only the character
256
+ // *before* the boundary and uses a lookahead for what follows, so the following character
257
+ // is never consumed and can still start the next boundary. That's what lets the three
258
+ // rules interleave in a single scan the way three sequential replaces used to:
259
+ // 1. a lowercase letter or digit before a capital ('ProperName').
260
+ // 2. a capital before a capital+lowercase pair, i.e. the last capital of a run ('HTMLElement').
261
+ // 3. a letter before a digit ('A100').
262
+ // '$&-' appends the dash after the matched character, then everything folds to lowercase.
263
+ return str.replace(/[a-z0-9](?=[A-Z])|[A-Z](?=[A-Z][a-z])|[a-zA-Z](?=\d)/g, '$&-').toLowerCase();
252
264
  },
253
265
 
254
266
  /**
@@ -264,13 +276,24 @@ let Util = {
264
276
  return str.replace(/-([a-z])/g, g => g[1].toUpperCase());
265
277
  },
266
278
 
279
+ /**
280
+ * Register Class as a custom element, unless it's registered already.
281
+ * @param Class {typeof HTMLElement}
282
+ * @param tagName {?string} Name to register under. Defaults to the dashed form of the class name.
283
+ * @return {string} The tag name Class is registered under, whether we just registered it or it
284
+ * was already in the registry under some other name. Callers that emit markup for the class
285
+ * use this instead of re-deriving the name, which guesses wrong for any class registered
286
+ * under a name that isn't camelToDashes(Class.name). */
267
287
  defineClass(Class, tagName) {
268
- if (!customElements[getName](Class)) { // If not previously defined.
269
- tagName = tagName || Util.camelToDashes(Class.name);
270
- if (!tagName.includes('-')) // Browsers require that web components always have a dash in the name.
271
- tagName += '-element';
272
- customElements[define](tagName, Class);
273
- }
288
+ let defined = customElements[getName](Class);
289
+ if (defined) // Previously defined.
290
+ return defined;
291
+
292
+ tagName = tagName || Util.camelToDashes(Class.name);
293
+ if (!tagName.includes('-')) // Browsers require that web components always have a dash in the name.
294
+ tagName += '-element';
295
+ customElements[define](tagName, Class);
296
+ return tagName;
274
297
  },
275
298
 
276
299
  /**
@@ -297,8 +320,8 @@ let Util = {
297
320
  return node.value; // String
298
321
  },
299
322
 
300
- isEvent(attrName) {
301
- return attrName.startsWith('on') && attrName in Globals$1.div;
323
+ isEvent(attribName) {
324
+ return attribName.startsWith('on') && attribName in Globals$1.div;
302
325
  },
303
326
 
304
327
  /**
@@ -335,16 +358,13 @@ let Util = {
335
358
  * @returns {Object} */
336
359
  splitAttribs(str) {
337
360
  let result = {};
338
- let attrs = (str + '') // Split string into multiple attributes.
339
- .split(/([\w-]+\s*=\s*(?:"[^"]*"|'[^']*'|\S+))/g)
340
- .map(text => text.trim())
341
- .filter(text => text.length);
342
361
 
343
- for (let attr of attrs) {
344
- let [name, value] = attr.split(/\s*=\s*/); // split on first equals.
345
- value = (value || '').replace(/^(['"])(.*)\1$/, '$2'); // trim value quotes if they match.
346
- result[name] = value;
347
- }
362
+ // One scan collects every name and its value. The value is optional so a boolean attribute
363
+ // written on its own ('disabled') still lands in the result with an empty value, and the three
364
+ // value alternatives capture *inside* the quotes so no separate quote-trimming pass is needed.
365
+ // Whatever doesn't look like an attribute name is skipped rather than becoming a bogus key.
366
+ (str + '').replace(/([\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g,
367
+ (_, name, dq, sq, bare) => result[name] = dq ?? sq ?? bare ?? '');
348
368
 
349
369
  return result;
350
370
  },
@@ -382,19 +402,14 @@ let Util = {
382
402
  * @param nodes {Node[]|NodeList}
383
403
  * @returns {Node[]} */
384
404
  trimEmptyNodes(nodes) {
385
- const shouldTrimNode = node =>
386
- node.nodeType !== Node.ELEMENT_NODE &&
387
- (node.nodeType !== Node.TEXT_NODE || node.textContent.trim() === '');
388
-
389
- // Convert nodeList to an array for easier manipulation
390
- const result = [...nodes];
405
+ // nodeType 1 is an element and 3 is a text node; the literals are what Node.ELEMENT_NODE
406
+ // and Node.TEXT_NODE are defined as, and they cost a fraction of the bytes.
407
+ let isEmpty = node => node.nodeType !== 1 && (node.nodeType !== 3 || !node.textContent.trim());
391
408
 
392
- // Trim from the start
393
- while (result.length > 0 && shouldTrimNode(result[0]))
409
+ let result = [...nodes]; // A NodeList can't shift() or pop().
410
+ while (result.length && isEmpty(result[0]))
394
411
  result.shift();
395
-
396
- // Trim from the end
397
- while (result.length > 0 && shouldTrimNode(result[result.length - 1]))
412
+ while (result.length && isEmpty(result[result.length - 1]))
398
413
  result.pop();
399
414
 
400
415
  return result;
@@ -410,7 +425,7 @@ let getName = 'getName';
410
425
 
411
426
 
412
427
  // For debugging only
413
- //#IFDEV
428
+ //#IFDEBUG
414
429
  function setIndent$1(items, level=1) {
415
430
  if (typeof items === 'string')
416
431
  items = items.split(/\r?\n/g);
@@ -510,6 +525,23 @@ class Path {
510
525
  * @type {Node[]} Cached result of getNodes() */
511
526
  nodesCache;
512
527
 
528
+ /** @type {boolean|undefined} True when this path provides an attribute of a web component
529
+ * (a -solarite-placeholder element). Only attribute paths ever set it true, but it's
530
+ * declared here on every Path because clone() and cloneWithNodes() copy it to every clone;
531
+ * declaring it keeps those stores from transitioning the clone's hidden class. */
532
+ isComponentAttrib;
533
+
534
+ /** @type {boolean} True when re-applying an expression identical to the one already
535
+ * applied is provably a no-op, so a re-render can skip this path entirely. Only event
536
+ * bindings qualify: binding the same handler to the same node again changes nothing,
537
+ * while an attribute or a child expression may have been altered outside the template. */
538
+ skipIfSame = false;
539
+
540
+ /** @type {boolean|undefined} True when the attribute is a live HTML property
541
+ * (checked/value/selected — Util.isHtmlProp), which users can flip underneath the
542
+ * template. Declared here for the same hidden-class reason as isComponentAttrib. */
543
+ isHtmlProperty;
544
+
513
545
  // Set only on Shell paths, never on cloned instances, so they're not declared as
514
546
  // class fields; that would cost a store per field on every clone:
515
547
  // nodeBeforeIndex {int} Index of nodeBefore among its parentNode's children.
@@ -523,7 +555,7 @@ class Path {
523
555
  constructor(nodeBefore, nodeMarker) {
524
556
  this.nodeBefore = nodeBefore;
525
557
  this.nodeMarker = nodeMarker;
526
- /*#IFDEV*/this.verify();/*#ENDIF*/
558
+ /*#IFDEBUG*/this.verify();/*#ENDIF*/
527
559
  }
528
560
 
529
561
  /**
@@ -545,7 +577,12 @@ class Path {
545
577
  * [[expr5], [expr6, expr7]] // arguments to second my-component constructor.
546
578
  * [expr5] // user attribute value.
547
579
  * [expr6, expr7] // role attribute value. */
548
- apply(exprs) {}
580
+ applyAll(exprs) {
581
+ //#IFDEBUG
582
+ assert(Array.isArray(exprs));
583
+ //#ENDIF
584
+ this.applySingle(exprs[0]);
585
+ }
549
586
 
550
587
  /**
551
588
  * Fast path used by NodeGroup.applyExprs() when every path consumes exactly one expression.
@@ -555,26 +592,12 @@ class Path {
555
592
 
556
593
  getExpressionCount() { return 1 }
557
594
 
558
-
559
595
  /**
560
- * Resolve nodeMarkerPath to new root.
561
- * TODO: Make clone() use this.*/
562
- getNewNodeMarker(newRoot, pathOffset) {
563
- let root = newRoot;
564
- let path = this.nodeMarkerPath;
565
- let pathLength = path.length - pathOffset;
566
- for (let i=pathLength-1; i>0; i--) { // Resolve the path.
567
- //#IFDEV
568
- assert(root.childNodes[path[i]]);
569
- //#ENDIF
570
- root = root.childNodes[path[i]];
571
- }
572
- let childNodes = root.childNodes;
573
-
574
- return pathLength
575
- ? childNodes[path[0]]
576
- : newRoot;
577
- }
596
+ * The value a path hands to a component constructor, for the single-expression paths.
597
+ * PathToAttribValue overrides this to join its surrounding static strings.
598
+ * @param exprs {Expr[]}
599
+ * @return {Expr} */
600
+ getValue(exprs) { return exprs[0] }
578
601
 
579
602
 
580
603
  /**
@@ -584,7 +607,7 @@ class Path {
584
607
  * @param nodeMarker {Node}
585
608
  * @return {Path} */
586
609
  cloneWithNodes(nodeBefore, nodeMarker) {
587
- let result = new this.constructor(nodeBefore, nodeMarker, this.attrName, this.attrValue);
610
+ let result = new this.constructor(nodeBefore, nodeMarker, this.attribName, this.attrValue);
588
611
  result.isComponentAttrib = this.isComponentAttrib;
589
612
  result.wholeParent = this.wholeParent;
590
613
  result.isHtmlProperty = this.isHtmlProperty;
@@ -596,41 +619,25 @@ class Path {
596
619
  * @param pathOffset {int}
597
620
  * @return {Path} */
598
621
  clone(newRoot, pathOffset=0) {
599
- /*#IFDEV*/this.verify();/*#ENDIF*/
600
-
601
- // Resolve node paths.
602
- let nodeMarker, nodeBefore;
603
- let root = newRoot;
604
- let path = this.nodeMarkerPath;
605
- let pathLength = path.length - pathOffset;
606
- for (let i=pathLength-1; i>0; i--) { // Resolve the path.
607
- //#IFDEV
608
- assert(root.childNodes[path[i]]);
609
- //#ENDIF
610
- root = root.childNodes[path[i]];
611
- }
612
- let childNodes = root.childNodes;
613
-
614
- nodeMarker = pathLength
615
- ? childNodes[path[0]]
616
- : newRoot;
622
+ /*#IFDEBUG*/this.verify();/*#ENDIF*/
623
+
624
+ // Resolve node paths. nodeBefore is always a sibling of nodeMarker (Shell builds it from
625
+ // nodeMarker.previousSibling, or inserts a comment immediately before it), so the list
626
+ // nodeBeforeIndex counts within is the marker's own parent's childNodes. An empty path
627
+ // leaves the marker as newRoot itself, and then that list is newRoot's children.
628
+ let nodeBefore;
629
+ let nodeMarker = Path.resolve(newRoot, this.nodeMarkerPath, pathOffset);
617
630
  if (this.nodeBefore) {
618
- //#IFDEV
631
+ let childNodes = (nodeMarker === newRoot ? newRoot : nodeMarker.parentNode).childNodes;
632
+ //#IFDEBUG
619
633
  assert(childNodes[this.nodeBeforeIndex]);
620
634
  //#ENDIF
621
635
  nodeBefore = childNodes[this.nodeBeforeIndex];
622
-
623
636
  }
624
637
 
625
- let result = new this.constructor(nodeBefore, nodeMarker, this.attrName, this.attrValue);
626
-
627
- result.isComponentAttrib = this.isComponentAttrib;
628
- result.wholeParent = this.wholeParent;
629
-
630
- // TODO: Put this in PathToAttribValue.clone().
631
- result.isHtmlProperty = this.isHtmlProperty;
638
+ let result = this.cloneWithNodes(nodeBefore, nodeMarker);
632
639
 
633
- //#IFDEV
640
+ //#IFDEBUG
634
641
  result.verify();
635
642
  //#ENDIF
636
643
 
@@ -654,14 +661,21 @@ class Path {
654
661
  * Note that the path is backward, with the outermost element at the end.
655
662
  * @param root {HTMLElement|Document|DocumentFragment|ParentNode}
656
663
  * @param path {int[]}
664
+ * @param skip {int} How many of the outermost steps to leave off, for when root is
665
+ * already that many levels down from where the path was recorded. An empty walk
666
+ * (skip === path.length) returns root itself.
657
667
  * @returns {Node|HTMLElement|HTMLStyleElement} */
658
- static resolve(root, path) {
659
- for (let i=path.length-1; i>=0; i--)
668
+ static resolve(root, path, skip=0) {
669
+ for (let i=path.length-1-skip; i>=0; i--) {
670
+ //#IFDEBUG
671
+ assert(root.childNodes[path[i]]);
672
+ //#ENDIF
660
673
  root = root.childNodes[path[i]];
674
+ }
661
675
  return root;
662
676
  }
663
677
 
664
- //#IFDEV
678
+ //#IFDEBUG
665
679
 
666
680
  /** @return {HTMLElement|ParentNode} */
667
681
  getParentNode() {
@@ -695,122 +709,236 @@ class Path {
695
709
  //#ENDIF
696
710
  }
697
711
 
698
- class HtmlParser {
699
- constructor() {
700
- this.defaultState = {
701
- context: HtmlParser.Text, // possible values: 'TEXT', 'TAG', 'ATTRIBUTE'
702
- quote: null, // possible values: null, '"', "'"
703
- buffer: '',
704
- lastChar: null
705
- };
706
- this.state = {...this.defaultState};
712
+ /**
713
+ * A key-scoped selection that updates only the rows it actually affects.
714
+ *
715
+ * Rendering a list normally means calling render() and letting the reconciler decide what
716
+ * changed. That is the right default, but it is a poor fit for a selection: moving a
717
+ * highlight from one row of a thousand to another changes two attributes, and asking the
718
+ * reconciler about it means walking the whole list to discover that fact.
719
+ *
720
+ * A Selector short-circuits that. when() hands each row one of exactly two objects — the
721
+ * selected one or the unselected one — and set() reaches the two rows that change through
722
+ * the list they were rendered into, writing their attributes directly with no render() call.
723
+ *
724
+ * This is the same primitive as Solid's createSelector, adapted to a library that has no
725
+ * signals: the list, not a subscription, is what carries the binding.
726
+ *
727
+ * Because set() locates a row by its key, **the rows must be keyed** — the row template needs
728
+ * a key=${...} attribute. set() throws on an unkeyed list rather than silently doing nothing.
729
+ */
730
+
731
+ /**
732
+ * The value an attribute is bound to. There are only ever **two** of these per Selector,
733
+ * both built in its constructor: one standing for "this row is the selected one" and one for
734
+ * "this row is not". when() returns whichever of the two the row's key calls for.
735
+ *
736
+ * Two singletons rather than one object per key is what makes a selector free to create. A
737
+ * row of a freshly-drawn list with nothing selected gets the unselected singleton, whose
738
+ * value is the off value, so there is no allocation, no map entry and no DOM call — only the
739
+ * two stores that record where the list lives. It also sharpens the re-render skip: a row's
740
+ * expression changes identity exactly when its selectedness changes, so
741
+ * NodeGroup.rewriteStamp() rewrites the rows that gained or lost the selection and no others.
742
+ */
743
+ class SelectorRef {
744
+
745
+ /** @type {Selector} */
746
+ selector;
747
+
748
+ /** @type {boolean} True on the singleton that stands for the selected row. */
749
+ selected;
750
+
751
+ constructor(selector, selected) {
752
+ this.selector = selector;
753
+ this.selected = selected;
707
754
  }
708
755
 
709
- reset() {
710
- this.state = {...this.defaultState};
711
- return this.state.context;
756
+ /** @return {*} The value this ref currently stands for. */
757
+ value() {
758
+ let s = this.selector;
759
+ return this.selected ? s.onValue : s.offValue;
712
760
  }
713
761
 
714
762
  /**
715
- * Parse the next chunk of html, starting with the same context we left off with from the previous chunk.
716
- * @param html {string}
717
- * @param onContextChange {?function(html:string, index:int, prevContext:string, nextContext:string)}
718
- * Called every time the context changes, and again at the last context.
719
- * @return {('Attribute','Text','Tag')} The context at the end of html. */
720
- parse(html, onContextChange=null) {
721
- if (html === null)
722
- return this.reset();
723
-
724
- for (let i = 0; i < html.length; i++) {
725
- const char = html[i];
726
- switch (this.state.context) {
727
- case HtmlParser.Text:
728
- if (char === '<' && html[i + 1].match(/[/a-z!]/i)) { // Start of a tag or comment.
729
- onContextChange?.(html, i, this.state.context, HtmlParser.Tag);
730
- this.state.context = HtmlParser.Tag;
731
- this.state.buffer = '';
732
- }
733
- break;
734
- case HtmlParser.Tag:
735
- if (char === '>') {
736
- onContextChange?.(html, i+1, this.state.context, HtmlParser.Text);
737
- this.state.context = HtmlParser.Text;
738
- this.state.quote = null;
739
- this.state.buffer = '';
740
- }
741
- else if (char === ' ' && !this.state.buffer) {
742
- // No attribute name is present. Skipping the space.
743
- continue;
744
- }
745
- else if (char === ' ' || char === '/' || char === '?') {
746
- this.state.buffer = ''; // Reset the buffer when a delimiter or potential self-closing sign is found.
747
- }
748
- else if (char === '"' || char === "'" || char === '=') {
749
- onContextChange?.(html, i, this.state.context, HtmlParser.Attribute);
750
- this.state.context = HtmlParser.Attribute;
751
- this.state.quote = char === '=' ? null : char;
752
- this.state.buffer = '';
753
- }
754
- else
755
- this.state.buffer += char;
756
- break;
757
- case HtmlParser.Attribute:
758
- // Start an attribute quote.
759
- if (!this.state.quote && !this.state.buffer.length && (char === '"' || char === "'")) {
760
- this.state.quote = char;
761
- }
762
- else if (char === this.state.quote || (!this.state.quote && this.state.buffer.length)) {
763
- onContextChange?.(html, i, this.state.context, HtmlParser.Tag);
764
- this.state.context = HtmlParser.Tag;
765
- this.state.quote = null;
766
- this.state.buffer = '';
767
- }
768
- else if (!this.state.quote && char === '>') {
769
- onContextChange?.(html, i+1, this.state.context, HtmlParser.Text);
770
- this.state.context = HtmlParser.Text;
771
- this.state.quote = null;
772
- this.state.buffer = '';
773
- }
774
- else if (char !== ' ')
775
- this.state.buffer += char;
776
-
777
- break;
778
- }
763
+ * Write this ref's value to an element's attribute, and tell the selector where the list
764
+ * is so that a later set() can find any row in it.
765
+ *
766
+ * Called by PathToAttribValue when the ref appears as an attribute expression. It runs
767
+ * once per row per render, so it is deliberately nothing but two stores and a write that
768
+ * the common case skips.
769
+ *
770
+ * @param node {Node} The element carrying the attribute.
771
+ * @param attribName {string}
772
+ * @param parentNg {NodeGroup} The row this attribute belongs to. */
773
+ bind(node, attribName, parentNg) {
774
+ // set() writes through the row's own root element, so an attribute anywhere deeper
775
+ // would be found at bind time and then written somewhere else at set() time. Catching
776
+ // it here turns a silently misplaced attribute into a clear message. It SHIPS: it is not
777
+ // in a debug-strip block, and it must not be, because the failure it catches is silent.
778
+ if (parentNg.startNode !== node)
779
+ throw new Error(`Solarite: a selector must be on the row's root element.`);
780
+
781
+ let s = this.selector;
782
+ s.attribName = attribName;
783
+ s.path = parentNg.parentPath;
784
+
785
+ let v = this.selected ? s.onValue : s.offValue;
786
+
787
+ // Matches PathToAttribValue.applySingle: an empty or falsy value leaves no attribute
788
+ // behind, so a selector never adds markup a hand-written implementation wouldn't have.
789
+ if (v === '' || v === false || v === null || v === undefined) {
790
+ // A just-cloned row provably carries no attribute of this name yet, so the
791
+ // removeAttribute — a DOM call for every row of the list — can be skipped.
792
+ if (parentNg.firstApply !== true)
793
+ node.removeAttribute(attribName);
779
794
  }
780
- onContextChange?.(html, html.length, this.state.context, null);
781
- return this.state.context;
795
+ else
796
+ node.setAttribute(attribName, v);
782
797
  }
783
798
  }
784
799
 
785
- HtmlParser.Attribute = 'Attribute';
786
- HtmlParser.Text = 'Text';
787
- HtmlParser.Tag = 'Tag';
800
+ /**
801
+ * Created by h.selector(). Holds one selected key.
802
+ *
803
+ * Only attribute expressions can bind a selector; using one as element content throws,
804
+ * because writing text through this path would need bookkeeping the two-node fast case
805
+ * doesn't want.
806
+ *
807
+ * The selector keeps **no per-row state at all** — no map of keys, nothing to sweep, and
808
+ * nothing that could pin a removed row's element in memory. All it remembers is which
809
+ * attribute it drives and which list it was rendered into.
810
+ */
811
+ class Selector {
812
+
813
+ /** @type {*} The selected key, or null. */
814
+ #key = null;
815
+
816
+ /** @type {SelectorRef} Returned by when() for the row whose key is selected. */
817
+ #on = new SelectorRef(this, true);
818
+
819
+ /** @type {SelectorRef} Returned by when() for every other row. */
820
+ #off = new SelectorRef(this, false);
821
+
822
+ /** @type {*} Value the bound attribute takes for the selected key. Held here rather than
823
+ * on each ref, so the two refs stay interchangeable between call sites. */
824
+ onValue;
825
+
826
+ /** @type {*} Value it takes for every other key. */
827
+ offValue = '';
828
+
829
+ /** @type {?string} The attribute this selector drives, learned when a row binds. */
830
+ attribName = null;
831
+
832
+ /** @type {?PathToNodes} The list this selector's rows were rendered into, learned when a
833
+ * row binds. set() asks it for the NodeGroup holding a given key. */
834
+ path = null;
835
+
836
+ /** @param key {*} The initially selected key. */
837
+ constructor(key = null) {
838
+ this.#key = key;
839
+ }
840
+
841
+ /** @return {*} The selected key. */
842
+ get key() {
843
+ return this.#key;
844
+ }
845
+
846
+ /**
847
+ * Bind an attribute to whether key is the selected one.
848
+ *
849
+ * h`<tr key=${row.id} class=${sel.when(row.id, 'danger')}>`
850
+ *
851
+ * @param key {*} This row's key.
852
+ * @param on {*} Value the attribute takes when key is selected.
853
+ * @param off {*} Value it takes otherwise. '' removes the attribute.
854
+ * @return {SelectorRef} */
855
+ when(key, on, off = '') {
856
+ this.onValue = on;
857
+ this.offValue = off;
858
+ return key === this.#key ? this.#on : this.#off;
859
+ }
860
+
861
+ /**
862
+ * Move the selection. Writes at most two attributes — the row losing the selection and
863
+ * the row gaining it — and touches nothing else. There is no render() call.
864
+ * @param key {*} The newly selected key, or null for none. */
865
+ set(key) {
866
+ let old = this.#key;
867
+ if (old === key)
868
+ return;
869
+ this.#key = key;
870
+
871
+ // Nothing has rendered a row yet, so there is no list to write into. The new key
872
+ // still takes effect: rows drawn later come up already carrying the attribute.
873
+ if (this.path === null)
874
+ return;
875
+
876
+ this.#write(old, this.offValue);
877
+ this.#write(key, this.onValue);
878
+ }
879
+
880
+ /**
881
+ * Find the row holding key and give its root element the value v.
882
+ * @param key {*}
883
+ * @param v {*} */
884
+ #write(key, v) {
885
+ if (key === null || key === undefined)
886
+ return;
887
+
888
+ let ngs = this.path.nodeGroups;
889
+ if (ngs === null || ngs.length === 0)
890
+ return;
891
+
892
+ if (ngs[0].key === undefined)
893
+ throw new Error('Solarite: a selector must be on a keyed list, as key=${...}.');
894
+
895
+ // A linear scan over the rows. The list is walked only when the selection actually
896
+ // moves — twice per user click, not once per row per render — so a thousand pointer
897
+ // comparisons here cost far less than the per-row index that would avoid them.
898
+ let ng = null;
899
+ for (let i = 0; i < ngs.length; i++)
900
+ if (ngs[i].key === key) {
901
+ ng = ngs[i];
902
+ break;
903
+ }
904
+ if (ng === null)
905
+ return;
906
+
907
+ // The selector owns an attribute on the row's own root element, which for a
908
+ // single-root row template is exactly the NodeGroup's startNode.
909
+ let node = ng.startNode;
910
+ if (node === null || node.nodeType !== 1)
911
+ return;
912
+
913
+ if (v === '' || v === false || v === null || v === undefined)
914
+ node.removeAttribute(this.attribName);
915
+ else
916
+ node.setAttribute(this.attribName, v);
917
+ }
918
+ }
788
919
 
789
920
  class PathToAttribValue extends Path {
790
921
 
791
922
  /** @type {?string} Used only if type=AttribType.Value. */
792
- attrName;
923
+ attribName;
793
924
 
794
925
  /**
795
926
  * @type {?string[]} Used only if type=AttribType.Value. If null, use one expr to set the whole attribute value. */
796
927
  attrValue;
797
928
 
798
- /** @type {boolean} Provides value for attribute on a component. */
799
- isComponent;
929
+ // isComponentAttrib and isHtmlProperty are declared on the Path base class.
800
930
 
801
- isHtmlProperty;
802
-
803
- constructor(nodeBefore, nodeMarker, attrName=null, attrValue=null) {
931
+ constructor(nodeBefore, nodeMarker, attribName=null, attrValue=null) {
804
932
  super(null, nodeMarker);
805
- this.attrName = attrName;
933
+ this.attribName = attribName;
806
934
  this.attrValue = attrValue;
807
935
  }
808
936
 
809
937
  /**
810
938
  * Set the value of an attribute. This can be for any attribute, not just attributes named "value".
811
939
  * @param exprs {Expr[]} */
812
- apply(exprs) {
813
- //#IFDEV
940
+ applyAll(exprs) {
941
+ //#IFDEBUG
814
942
  assert(Array.isArray(exprs));
815
943
  //#ENDIF
816
944
 
@@ -823,14 +951,14 @@ class PathToAttribValue extends Path {
823
951
  // Only update attributes if the value has changed.
824
952
  // This is needed for setting input.value, .checked, option.selected, etc.
825
953
  let oldVal = isProp
826
- ? node[this.attrName]
827
- : node.getAttribute(this.attrName);
954
+ ? node[this.attribName]
955
+ : node.getAttribute(this.attribName);
828
956
  if (oldVal !== joinedValue) {
829
957
  if (isProp)
830
- node[this.attrName] = joinedValue;
831
- else if (this.attrName === 'value' && node.hasAttribute('contenteditable'))
958
+ node[this.attribName] = joinedValue;
959
+ else if (this.attribName === 'value' && node.hasAttribute('contenteditable'))
832
960
  node.innerHTML = joinedValue;
833
- node.setAttribute(this.attrName, joinedValue);
961
+ node.setAttribute(this.attribName, joinedValue);
834
962
  }
835
963
  }
836
964
  else
@@ -843,7 +971,7 @@ class PathToAttribValue extends Path {
843
971
  applySingle(expr) {
844
972
  // One expression surrounded by strings, e.g. class="a ${b} c". Join through apply().
845
973
  if (this.attrValue)
846
- return this.apply([expr]);
974
+ return this.applyAll([expr]);
847
975
 
848
976
  let node = this.nodeMarker;
849
977
 
@@ -864,12 +992,12 @@ class PathToAttribValue extends Path {
864
992
  let [obj, path] = [expr[0], expr.slice(1)];
865
993
 
866
994
  if (!obj)
867
- throw new Error(`Solarite cannot bind to <${node.tagName.toLowerCase()} ${this.attrName}=\${[${expr.map(item => item ? `'${item}'` : item+'').join(', ')}]}>.`);
995
+ throw new Error(`Solarite cannot bind ${this.attribName} to ${obj}.`);
868
996
 
869
997
  let value = delve(obj, path);
870
998
 
871
999
  // Special case to allow setting select-multiple value from an array
872
- if (this.attrName === 'value' && node.type === 'select-multiple' && Array.isArray(value)) {
1000
+ if (this.attribName === 'value' && node.type === 'select-multiple' && Array.isArray(value)) {
873
1001
  // Set the .selected property on the options having a value within value.
874
1002
  let strValues = value.map(v => v + '');
875
1003
  for (let option of node.options)
@@ -885,7 +1013,7 @@ class PathToAttribValue extends Path {
885
1013
  const strValue = Util.isFalsy(value) ? '' : value;
886
1014
 
887
1015
  // Special case for contenteditable
888
- if (this.attrName === 'value' && node.hasAttribute('contenteditable')) {
1016
+ if (this.attribName === 'value' && node.hasAttribute('contenteditable')) {
889
1017
  const existingValue = node.innerHTML;
890
1018
  if (strValue !== existingValue)
891
1019
  node.innerHTML = strValue;
@@ -894,28 +1022,39 @@ class PathToAttribValue extends Path {
894
1022
 
895
1023
  // If we don't have this condition, when we call render(), the browser will scroll to the currently
896
1024
  // selected item in a <select> and mess up manually scrolling to a different value.
897
- if (strValue !== node[this.attrName])
898
- node[this.attrName] = strValue;
1025
+ if (strValue !== node[this.attribName])
1026
+ node[this.attribName] = strValue;
899
1027
  }
900
1028
  }
901
1029
 
902
1030
  // TODO: We need to remove any old listeners, like in bindEventAttribute.
903
1031
  // Does bindEvent() now handle that?
904
1032
  let func = () => {
905
- let value = (this.attrName === 'value' || node.type === 'radio')
1033
+ let value = (this.attribName === 'value' || node.type === 'radio')
906
1034
  ? Util.getInputValue(node)
907
- : node[this.attrName];
1035
+ : node[this.attribName];
908
1036
  delve(obj, path, value);
909
1037
  };
910
1038
 
911
1039
  // We use capture so we update the values before other events added by the user.
912
1040
  // TODO: Bind to scroll events also?
913
1041
  // What about resize events and width/height?
914
- this.bindEvent(node, this.parentNg.getRootNode(), this.attrName, 'input', func, null, true);
1042
+ this.bindEvent(node, this.parentNg.getRootEl(), this.attribName, 'input', func, null, true);
915
1043
  }
916
1044
 
917
1045
  // Regular attribute
918
1046
  else {
1047
+ // A selection binding (h.selector().when()) writes its own value and tells the
1048
+ // selector which list this row belongs to, so a later change of selection reaches
1049
+ // the attribute directly instead of going back through render(). The typeof test
1050
+ // keeps ordinary string attributes — nearly all of them — from paying for the
1051
+ // prototype check.
1052
+ if (typeof expr === 'object' && expr instanceof SelectorRef) {
1053
+ if (!this.isComponentAttrib)
1054
+ expr.bind(node, this.attribName, this.parentNg);
1055
+ return;
1056
+ }
1057
+
919
1058
  // Cache this on Path.isHtmlProperty when Shell creates the props.
920
1059
  // Have Path.clone() copy .isHtmlProperty?
921
1060
  let isProp = this.isHtmlProperty;
@@ -928,43 +1067,53 @@ class PathToAttribValue extends Path {
928
1067
  else
929
1068
  expr = Util.makePrimitive(expr);
930
1069
 
931
- // Values to toggle an attribute
932
- if (expr === undefined || expr === false || expr === null) { // Util.isFalsy() inlined.
933
- if (isProp)
934
- node[this.attrName] = false;
935
- node.removeAttribute(this.attrName);
1070
+ // Values that remove an attribute. The empty string is included so that an attribute
1071
+ // disappears whenever its expression is empty, instead of only when it happened to be
1072
+ // absent already. makePrimitive() above turns null into '', so plain null lands here
1073
+ // too; the explicit null test still matters for a function expression returning null,
1074
+ // which skips makePrimitive.
1075
+ // An html property is exempt: on those, '' is a real value meaning "empty", as when
1076
+ // clearing an <input>, so it belongs on the assignment path below.
1077
+ if (expr === undefined || expr === false || expr === null || (expr === '' && !isProp)) {
1078
+ if (isProp) {
1079
+ // Clear the property with a value of its own type. Assigning false to a string
1080
+ // property such as input.value would put the text "false" in the field.
1081
+ let old = node[this.attribName];
1082
+ node[this.attribName] = typeof old === 'boolean' ? false : '';
1083
+ }
1084
+ node.removeAttribute(this.attribName);
936
1085
  }
937
1086
  else if (expr === true) {
938
1087
  if (isProp)
939
- node[this.attrName] = true;
940
- node.setAttribute(this.attrName, '');
1088
+ node[this.attribName] = true;
1089
+ node.setAttribute(this.attribName, '');
941
1090
  }
942
1091
 
943
1092
  // A non-toggled attribute
944
1093
  else {
945
1094
  // Only update attributes if the value has changed.
946
1095
  // This is needed for setting input.value, .checked, option.selected, etc.
947
- // A missing attribute counts as '', so empty values don't write empty attributes.
1096
+ // Non-property attributes never reach here with '', since that removes above.
948
1097
  let oldVal = isProp
949
- ? node[this.attrName]
950
- : node.getAttribute(this.attrName) ?? '';
1098
+ ? node[this.attribName]
1099
+ : node.getAttribute(this.attribName) ?? '';
951
1100
  if (oldVal !== expr) {
952
1101
 
953
1102
  // <textarea value=${expr}></textarea>
954
1103
  // Without this branch we have no way to set the value of a textarea,
955
1104
  // since we also prohibit expressions that are a child of textarea.
956
1105
  if (isProp)
957
- node[this.attrName] = expr;
1106
+ node[this.attribName] = expr;
958
1107
 
959
1108
  // Allow one-way binding to contenteditable value attribute.
960
1109
  // Contenteditables normally don't have a value attribute and have their content set via innerHTML.
961
1110
  // Solarite doesn't allow contenteditables to have expressions as their children.
962
- else if (this.attrName === 'value' && node.hasAttribute('contenteditable')) {
1111
+ else if (this.attribName === 'value' && node.hasAttribute('contenteditable')) {
963
1112
  node.innerHTML = expr;
964
1113
  }
965
1114
 
966
1115
  // TODO: Putting an 'else' here would be more performant
967
- node.setAttribute(this.attrName, expr);
1116
+ node.setAttribute(this.attribName, expr);
968
1117
  }
969
1118
  }
970
1119
  }
@@ -978,14 +1127,14 @@ class PathToAttribValue extends Path {
978
1127
  * @return {string} The joined values of the expressions, or the first expression if there are no strings. */
979
1128
  getValue(exprs) {
980
1129
 
981
- //#IFDEV
1130
+ //#IFDEBUG
982
1131
  assert(Array.isArray(exprs));
983
1132
  //#ENDIF
984
1133
  //if (!Array.isArray(exprs))
985
1134
  // return exprs;
986
1135
 
987
1136
  if (!this.attrValue) {// If it's not multiple paths inside a single attribute, return first (and only) expression.
988
- //#IFDEV
1137
+ //#IFDEBUG
989
1138
  assert(exprs.length === 1);
990
1139
  //#ENDIF
991
1140
  return exprs[0];
@@ -996,6 +1145,18 @@ class PathToAttribValue extends Path {
996
1145
  for (let i = 0; i < values.length; i++) {
997
1146
  result.push(values[i]);
998
1147
  if (i < values.length - 1) {
1148
+ // A selection binding has to own the whole attribute, because its whole point is
1149
+ // writing that attribute without re-rendering, which it can't do if the rest of
1150
+ // the value comes from expressions it doesn't know about. Whether a selector sits
1151
+ // inside a multi-part attribute is fixed by the shape of the template and never by
1152
+ // the data, so this can only be an authoring mistake, and it always surfaces on the
1153
+ // template's very first render -- exactly like the placement check in
1154
+ // SelectorRef.bind(). That makes it safe to strip from the built file, where the
1155
+ // throw is the only thing lost: makePrimitive() then turns the ref into '' and the
1156
+ // attribute is written from its constant parts alone. Stripping it also keeps a
1157
+ // per-expression instanceof out of the multi-part attribute loop.
1158
+ if (typeof exprs[i] === 'object' && exprs[i] instanceof SelectorRef)
1159
+ throw new Error(`Solarite: a selector must own the whole ${this.attribName} attribute.`);
999
1160
  let val = Util.makePrimitive(exprs[i]);
1000
1161
  if (!Util.isFalsy(val))
1001
1162
  result.push(val);
@@ -1016,17 +1177,41 @@ class PathToAttribValue extends Path {
1016
1177
  /**
1017
1178
  * @param funcAndArgs {?Array} The [func, ...args] array from the template, or null if func stands alone. */
1018
1179
  bindEvent(node, root, key, eventName, func, funcAndArgs, capture=false) {
1180
+ //#IFDEBUG
1181
+ // Both callers already guarantee a function, so this only catches a future third caller.
1182
+ // PathToEvent.applySingle() rejects every shape a template can produce and names the
1183
+ // offending value, and the two-way binding path above passes a closure it just made
1184
+ // here, so nothing a page author writes can reach this line. That makes it dev-only:
1185
+ // stripping it from the built file costs no diagnostic that the surviving throw in
1186
+ // PathToEvent doesn't already give, with a better message.
1019
1187
  if (typeof func !== 'function')
1020
- throw new Error(`Solarite cannot bind to <${node.tagName.toLowerCase()} ${this.attrName}=\${${func}}> because it's not a function.`);
1188
+ throw new Error(`Solarite cannot bind to <${node.tagName.toLowerCase()} ${this.attribName}=\${${func}}> because it's not a function.`);
1189
+ //#ENDIF
1021
1190
 
1022
- // Whether to delegate is decided in registerBinding(), which only runs for a NEW binding.
1023
- // Re-renders rebind existing rows (just updating binding.args below), so they skip the
1024
- // options lookup + delegatableEvents check entirely.
1025
- let options = this.parentNg.rootNg.options;
1191
+ // Delegated path: a bubbling event (when the root's options allow it, the default)
1192
+ // stores its handler directly on the node as a per-event-type Symbol expando, with no
1193
+ // EventBinding object and no addEventListener call. The root-level dispatcher reads
1194
+ // these expandos while walking up from the event target. Re-renders just overwrite
1195
+ // the property. this.delegatedKey is set by the PathToEvent constructor only for
1196
+ // delegatable event names, so this test also excludes non-bubbling events.
1197
+ if (capture === false && this.delegatedKey !== undefined) {
1198
+ let opt = this.parentNg.rootNg.renderOptions?.eventDelegation ?? true;
1199
+ let toDocument = opt === 'document';
1200
+ if (opt !== false && (opt === true || toDocument || opt.includes(eventName))) {
1201
+ let dk = this.delegatedKey;
1202
+ if (node[dk] === undefined) // First binding of this type on this node.
1203
+ ensureDelegatedDispatcher(root, eventName, toDocument);
1204
+ // Array-form bindings (onclick=${[fn, arg]}, the hot per-row case) store the
1205
+ // template's own [func, ...args] array; a plain function is stored bare.
1206
+ // Either way, nothing is allocated.
1207
+ node[dk] = funcAndArgs || func;
1208
+ node[delegatedRootKey] = root;
1209
+ return;
1210
+ }
1211
+ }
1026
1212
 
1027
- // Store the callable as a single [func, ...args] array. Array-form bindings
1028
- // (onclick=${[fn, arg]}, the hot per-row case) pass it through with no allocation;
1029
- // a plain function allocates a one-element array, which is rare (buttons, two-way).
1213
+ // Direct path: capture bindings, non-bubbling events, and eventDelegation:false.
1214
+ // Store the callable as a single [func, ...args] array.
1030
1215
  let args = funcAndArgs || [func];
1031
1216
 
1032
1217
  // One stable EventBinding object per node+key is registered with addEventListener
@@ -1036,7 +1221,7 @@ class PathToAttribValue extends Path {
1036
1221
  let nodeEvents = node[eventBindingsKey];
1037
1222
  if (nodeEvents === undefined) {
1038
1223
  let b = node[eventBindingsKey] = new EventBinding(root, node, key, args);
1039
- registerBinding(b, node, eventName, capture, options, root);
1224
+ node.addEventListener(eventName, b, capture);
1040
1225
  return;
1041
1226
  }
1042
1227
 
@@ -1054,7 +1239,7 @@ class PathToAttribValue extends Path {
1054
1239
  let map = node[eventBindingsKey] = {};
1055
1240
  map[nodeEvents.key] = nodeEvents;
1056
1241
  binding = map[key] = new EventBinding(root, node, key, args);
1057
- registerBinding(binding, node, eventName, capture, options, root);
1242
+ node.addEventListener(eventName, binding, capture);
1058
1243
  return;
1059
1244
  }
1060
1245
  }
@@ -1062,11 +1247,11 @@ class PathToAttribValue extends Path {
1062
1247
  binding = nodeEvents[key];
1063
1248
  if (!binding) {
1064
1249
  binding = nodeEvents[key] = new EventBinding(root, node, key, args);
1065
- registerBinding(binding, node, eventName, capture, options, root);
1250
+ node.addEventListener(eventName, binding, capture);
1066
1251
  return;
1067
1252
  }
1068
1253
  }
1069
- binding.root = root;
1254
+ binding.rootEl = root;
1070
1255
  binding.args = args;
1071
1256
  }
1072
1257
  }
@@ -1087,73 +1272,99 @@ function getEventBinding(node, key) {
1087
1272
  return b instanceof EventBinding ? (b.key === key ? b : undefined) : b[key];
1088
1273
  }
1089
1274
 
1090
- /**
1091
- * Attach a new EventBinding either directly or through the root component's delegated
1092
- * dispatcher. The dispatcher lives on the root element (not the document) so a component
1093
- * still receives delegated events while detached from the document, and events stay scoped
1094
- * to the component that rendered them. */
1095
- function registerBinding(binding, node, eventName, capture, options, root) {
1096
- // Bubbling events are delegated by default: they skip addEventListener entirely, and one
1097
- // root-level dispatcher per event type finds bindings by walking up from the event target.
1098
- // eventDelegation:false opts out; an array delegates only the named events. Capture
1099
- // bindings and non-bubbling events always stay direct.
1100
- let delegate = false;
1101
- if (capture === false) {
1102
- let opt = options?.eventDelegation ?? true;
1103
- if (opt !== false && delegatableEvents.has(eventName))
1104
- delegate = opt === true || opt.includes(eventName);
1105
- }
1106
-
1107
- if (delegate) {
1108
- binding.delegated = true;
1109
- let types = root[delegatedTypesKey];
1110
- if (types === undefined)
1111
- types = root[delegatedTypesKey] = new Set();
1112
- if (!types.has(eventName)) {
1113
- types.add(eventName);
1114
- root.addEventListener(eventName, delegatedDispatcher);
1115
- }
1116
- }
1117
- else
1118
- node.addEventListener(eventName, binding, capture);
1119
- }
1120
-
1121
1275
  // Bubbling events that one root-level listener can dispatch. Same set Solid.js delegates.
1122
1276
  const delegatableEvents = new Set(['beforeinput', 'click', 'contextmenu', 'dblclick', 'focusin', 'focusout',
1123
1277
  'input', 'keydown', 'keyup', 'mousedown', 'mousemove', 'mouseout', 'mouseover', 'mouseup',
1124
1278
  'pointerdown', 'pointermove', 'pointerout', 'pointerover', 'pointerup', 'touchend', 'touchmove', 'touchstart']);
1125
1279
 
1280
+ // One Symbol per delegated event type; nodes store their delegated handler under it.
1281
+ // Symbols (vs string expandos like Solid's $$click) can't collide with user properties.
1282
+ const delegatedKeys = {};
1283
+
1284
+ /**
1285
+ * Get the per-event-type Symbol key, or undefined for non-delegatable events.
1286
+ * Called once per PathToEvent construction, never per bind.
1287
+ * @param eventName {string}
1288
+ * @return {symbol|undefined} */
1289
+ function delegatedKeyFor(eventName) {
1290
+ if (!delegatableEvents.has(eventName))
1291
+ return undefined;
1292
+ return delegatedKeys[eventName] ??= Symbol('sol$' + eventName);
1293
+ }
1294
+
1295
+ // The component root a node's delegated handlers run with as `this`.
1296
+ // Exported so NodeGroup.applyStamp()'s compiled stamp program can write it directly.
1297
+ const delegatedRootKey = Symbol('solariteDelegatedRoot');
1298
+
1126
1299
  // Per-root-element Set of event types that already have a delegated dispatcher registered.
1127
1300
  const delegatedTypesKey = Symbol('solariteDelegatedTypes');
1128
1301
 
1302
+ /**
1303
+ * Register the delegated dispatcher for eventName on root if it isn't already.
1304
+ * Shared by bindEvent()'s delegated branch and NodeGroup.applyStamp()'s stamp program.
1305
+ *
1306
+ * With andDocument (the eventDelegation:'document' render option), the dispatcher is also
1307
+ * registered on the document, once per event type: a bound node that gets re-parented
1308
+ * OUTSIDE its root (e.g. a toolbar a dock parks in its own chrome) bubbles past the root's
1309
+ * listener, and only a document-level listener can still reach its handler. The
1310
+ * delegatedDoneKey marker keeps the two dispatchers from double-running the same event.
1311
+ * @param root {HTMLElement}
1312
+ * @param eventName {string}
1313
+ * @param andDocument {boolean} */
1314
+ function ensureDelegatedDispatcher(root, eventName, andDocument=false) {
1315
+ let types = root[delegatedTypesKey];
1316
+ if (types === undefined)
1317
+ types = root[delegatedTypesKey] = new Set();
1318
+ if (!types.has(eventName)) {
1319
+ types.add(eventName);
1320
+ root.addEventListener(eventName, delegatedDispatcher);
1321
+ }
1322
+ if (andDocument) {
1323
+ let doc = root.ownerDocument ?? document;
1324
+ let docTypes = doc[delegatedTypesKey];
1325
+ if (docTypes === undefined)
1326
+ docTypes = doc[delegatedTypesKey] = new Set();
1327
+ if (!docTypes.has(eventName)) {
1328
+ docTypes.add(eventName);
1329
+ doc.addEventListener(eventName, delegatedDispatcher);
1330
+ }
1331
+ }
1332
+ }
1333
+
1129
1334
  // Marks an event the innermost root dispatcher has already walked, so an outer root's
1130
1335
  // listener (when components are nested) skips it instead of dispatching the bindings again.
1131
1336
  const delegatedDoneKey = Symbol('solariteDelegated');
1132
1337
 
1133
1338
  /**
1134
1339
  * The per-root listener for each delegated event type. The first (innermost) root the
1135
- * bubbling event reaches walks from the event target upward, invoking delegated
1136
- * EventBindings stored on the nodes along the way; outer roots then see the done-marker and
1137
- * skip. Each binding carries its own root, so handlers in an outer component still run with
1138
- * the correct `this`. event.currentTarget is patched to the node whose binding is running,
1139
- * and restored after. stopPropagation() inside a handler ends the walk, mirroring native
1140
- * bubbling. */
1340
+ * bubbling event reaches walks from the event target upward, invoking delegated handlers
1341
+ * stored on the nodes along the way; outer roots then see the done-marker and skip.
1342
+ * Each node carries the root its handlers run with as `this` (see delegatedRootKey), so
1343
+ * handlers in an outer component still run with the correct component. event.currentTarget
1344
+ * is patched to the node whose handler is running, and restored after. stopPropagation()
1345
+ * inside a handler ends the walk, mirroring native bubbling. */
1141
1346
  function delegatedDispatcher(ev) {
1142
1347
  if (ev[delegatedDoneKey])
1143
1348
  return;
1144
1349
  ev[delegatedDoneKey] = true;
1145
- let type = ev.type;
1350
+ let dk = delegatedKeys[ev.type];
1146
1351
  let current = ev.target;
1147
1352
  Object.defineProperty(ev, 'currentTarget', {configurable: true, get() { return current }});
1148
1353
  while (current) {
1149
- let b = current[eventBindingsKey];
1150
- if (b !== undefined) {
1151
- let binding = b instanceof EventBinding ? b : b[type];
1152
- if (binding !== undefined && binding.delegated === true && binding.key === type) {
1153
- binding.handleEvent(ev);
1154
- if (ev.cancelBubble)
1155
- break;
1156
- }
1354
+ let a = current[dk];
1355
+ if (a !== undefined) {
1356
+ let root = current[delegatedRootKey];
1357
+ if (typeof a === 'function')
1358
+ a.call(root, ev, current);
1359
+ else
1360
+ switch (a.length) {
1361
+ case 1: a[0].call(root, ev, current); break;
1362
+ case 2: a[0].call(root, a[1], ev, current); break;
1363
+ case 3: a[0].call(root, a[1], a[2], ev, current); break;
1364
+ default: a[0].call(root, ...a.slice(1), ev, current);
1365
+ }
1366
+ if (ev.cancelBubble)
1367
+ break;
1157
1368
  }
1158
1369
  current = current.parentNode;
1159
1370
  }
@@ -1162,7 +1373,7 @@ function delegatedDispatcher(ev) {
1162
1373
 
1163
1374
  class EventBinding {
1164
1375
  constructor(root, node, key, args) {
1165
- this.root = root;
1376
+ this.rootEl = root;
1166
1377
  this.node = node;
1167
1378
  this.key = key;
1168
1379
 
@@ -1176,23 +1387,29 @@ class EventBinding {
1176
1387
  'handleEvent'(event) {
1177
1388
  let a = this.args;
1178
1389
  switch (a.length) {
1179
- case 1: return a[0].call(this.root, event, this.node);
1180
- case 2: return a[0].call(this.root, a[1], event, this.node);
1181
- case 3: return a[0].call(this.root, a[1], a[2], event, this.node);
1390
+ case 1: return a[0].call(this.rootEl, event, this.node);
1391
+ case 2: return a[0].call(this.rootEl, a[1], event, this.node);
1392
+ case 3: return a[0].call(this.rootEl, a[1], a[2], event, this.node);
1182
1393
  }
1183
- return a[0].call(this.root, ...a.slice(1), event, this.node);
1394
+ return a[0].call(this.rootEl, ...a.slice(1), event, this.node);
1184
1395
  }
1185
1396
  }
1186
1397
 
1187
1398
  // TODO: Merge this into PathToAttribValue?
1188
1399
  class PathToEvent extends PathToAttribValue {
1189
1400
 
1190
- /** @type {string} The attrName without the "on" prefix. */
1401
+ /** @type {string} The attribName without the "on" prefix. */
1191
1402
  eventName;
1192
1403
 
1193
- constructor(nodeBefore, nodeMarker, attrName=null, attrValue=null) {
1194
- super(null, nodeMarker, attrName, attrValue);
1195
- this.eventName = attrName ? attrName.slice(2) : null;
1404
+ /** @type {symbol|undefined} Expando key nodes store this event's delegated handler under.
1405
+ * Undefined for non-delegatable (non-bubbling) events; bindEvent() then binds directly. */
1406
+ delegatedKey;
1407
+
1408
+ constructor(nodeBefore, nodeMarker, attribName=null, attrValue=null) {
1409
+ super(null, nodeMarker, attribName, attrValue);
1410
+ this.skipIfSame = true;
1411
+ this.eventName = attribName ? attribName.slice(2) : null;
1412
+ this.delegatedKey = this.eventName !== null ? delegatedKeyFor(this.eventName) : undefined;
1196
1413
  }
1197
1414
 
1198
1415
  /**
@@ -1202,8 +1419,8 @@ class PathToEvent extends PathToAttribValue {
1202
1419
  * onclick=${[this, 'doSomething', 'meow']}
1203
1420
  *
1204
1421
  * @param exprs {Expr[]} Only the first is used.*/
1205
- apply(exprs) {
1206
- //#IFDEV
1422
+ applyAll(exprs) {
1423
+ //#IFDEBUG
1207
1424
  assert(Array.isArray(exprs));
1208
1425
  //#ENDIF
1209
1426
 
@@ -1211,7 +1428,7 @@ class PathToEvent extends PathToAttribValue {
1211
1428
  // We have expressions within a string attribute value that's not a Solarite event. E.g.
1212
1429
  // <div onclick="alert(${1});"
1213
1430
  if (this.attrValue?.length > 1) {
1214
- super.apply(exprs);
1431
+ super.applyAll(exprs);
1215
1432
  return;
1216
1433
  }
1217
1434
 
@@ -1223,16 +1440,16 @@ class PathToEvent extends PathToAttribValue {
1223
1440
  applySingle(expr) {
1224
1441
  // Expressions within a string attribute value that's not a Solarite event.
1225
1442
  if (this.attrValue?.length > 1)
1226
- return super.apply([expr]);
1443
+ return super.applyAll([expr]);
1227
1444
 
1228
1445
  // Don't bind events to component placeholders.
1229
1446
  // PathToComponent will do the binding later when it instantiates the component.
1230
1447
  if (this.isComponentAttrib && this.nodeMarker.tagName.endsWith('-SOLARITE-PLACEHOLDER'))
1231
1448
  return;
1232
1449
 
1233
- let root = this.parentNg.rootNg.root;
1450
+ let root = this.parentNg.rootNg.rootEl;
1234
1451
 
1235
- /*#IFDEV*/
1452
+ /*#IFDEBUG*/
1236
1453
  assert(root?.nodeType === 1);
1237
1454
  /*#ENDIF*/
1238
1455
 
@@ -1250,7 +1467,7 @@ class PathToEvent extends PathToAttribValue {
1250
1467
  expr = null;
1251
1468
  }
1252
1469
  else
1253
- throw new Error(`Invalid event binding: <${node.tagName.toLowerCase()} ${this.attrName}=\${${JSON.stringify(expr)}}>`);
1470
+ throw new Error(`Solarite: ${this.attribName}=\${...} is not a function.`);
1254
1471
 
1255
1472
  this.bindEvent(node, root, eventName, eventName, func, expr);
1256
1473
  }
@@ -1373,13 +1590,10 @@ function jsxToTemplate(tag, props, children=[], key=undefined) {
1373
1590
 
1374
1591
  // 2a. Custom element class => emit <tag-name ...props>children</tag-name>; PathToComponent
1375
1592
  // instantiates it exactly like a tagged-template component.
1376
- if (tag.prototype instanceof HTMLElement) {
1377
- Util.defineClass(tag);
1378
- let tagName = customElements.getName ? customElements.getName(tag) : Util.camelToDashes(tag.name);
1379
- if (tagName && !tagName.includes('-'))
1380
- tagName += '-element';
1381
- return buildIntrinsic(tagName, props, children, key);
1382
- }
1593
+ // defineClass() hands back the name it registered, or the name the class was already
1594
+ // registered under, so we never have to guess it a second time.
1595
+ if (tag.prototype instanceof HTMLElement)
1596
+ return buildIntrinsic(Util.defineClass(tag), props, children, key);
1383
1597
 
1384
1598
  // 2b. Plain function component: call it with props (+ children) and expect a Template back.
1385
1599
  let p = {};
@@ -1457,24 +1671,21 @@ class PathToAttribs extends Path {
1457
1671
  * @type {Set<string>} Used for type=AttribType.Multiple to remember the attributes that were added. */
1458
1672
  attrNames;
1459
1673
 
1460
- /** @type {boolean} Provides one or more attributes on a component. */
1461
- isComponent;
1674
+ /** @type {PathToEvent|PathToAttribValue|undefined} Cached sub-path for the JSX
1675
+ * whole-attribute fast path; see applyJsxAttr(). Declared so the first assignment
1676
+ * doesn't transition the hidden class. */
1677
+ jsxSub;
1678
+
1679
+ /** @type {?string} The attribute name jsxSub was built for. */
1680
+ jsxSubName;
1462
1681
 
1463
1682
  constructor(nodeBefore, nodeMarker) {
1464
- super(null, null);
1465
- this.nodeMarker = nodeMarker;
1683
+ // nodeBefore is discarded: an attribute path has no nodes of its own. The marker goes
1684
+ // straight through the base constructor rather than being stored a second time after it.
1685
+ super(null, nodeMarker);
1466
1686
  this.attrNames = new Set();
1467
1687
  }
1468
1688
 
1469
- /**
1470
- * @param exprs {Expr[][]} Only the first is used. */
1471
- apply(exprs) {
1472
- //#IFDEV
1473
- assert(Array.isArray(exprs));
1474
- //#ENDIF
1475
- this.applySingle(exprs[0]);
1476
- }
1477
-
1478
1689
  /**
1479
1690
  * @param expr {Expr} */
1480
1691
  applySingle(expr) {
@@ -1553,17 +1764,13 @@ class PathToAttribs extends Path {
1553
1764
  value = styleToCss(value);
1554
1765
  sub.applySingle(value);
1555
1766
  }
1556
-
1557
-
1558
- getExpressionCount() { return 1 }
1559
- getValue(exprs) { return exprs[0]; }
1560
1767
  }
1561
1768
 
1562
1769
  /**
1563
1770
  * Maps a string key to multiple values.
1564
1771
  * Values are stored in arrays because pushing them is much faster than Set operations,
1565
1772
  * and deleteAny() needs no iterator allocation.
1566
- * deleteAny() returns values first-in-first-out by advancing a head index (array.head)
1773
+ * deleteAny() returns values first-in-first-out by advancing a head index (array.hd)
1567
1774
  * instead of calling shift(), which would be O(n). */
1568
1775
  class MultiValueMap {
1569
1776
 
@@ -1591,7 +1798,7 @@ class MultiValueMap {
1591
1798
  let array = data[key];
1592
1799
  if (!array)
1593
1800
  data[key] = [value];
1594
- else if (array.length - (array.head || 0) < max)
1801
+ else if (array.length - (array.hd || 0) < max)
1595
1802
  array.push(value);
1596
1803
  }
1597
1804
 
@@ -1605,20 +1812,75 @@ class MultiValueMap {
1605
1812
  if (!array) // slower than pre-check.
1606
1813
  return undefined;
1607
1814
 
1608
- let head = array.head || 0;
1815
+ let head = array.hd || 0;
1609
1816
  let result = array[head];
1610
1817
  head++;
1611
1818
  if (head >= array.length)
1612
1819
  delete data[key];
1613
1820
  else
1614
- array.head = head;
1821
+ array.hd = head;
1615
1822
 
1616
1823
  return result;
1617
1824
  }
1618
1825
  }
1619
1826
 
1827
+ /**
1828
+ * A list of items plus the function that builds one item's Template, as returned by h.map().
1829
+ *
1830
+ * Handing the reconciler the source items instead of an array of Templates is what makes
1831
+ * h.map() cheap on a long list: a row whose item is the same object it was built from needs
1832
+ * neither a Template built for it nor a cache lookup to find one, just an identity check
1833
+ * against the item the row already remembers. Rows that moved are recognized too — see
1834
+ * PathToNodes.applyMapped(), which follows a shifted list's offset and, failing that, matches
1835
+ * items against the Templates the previous render built.
1836
+ */
1837
+ class MappedList {
1838
+
1839
+ /** @type {Array} */
1840
+ items;
1841
+
1842
+ /** @type {function(*):Template} */
1843
+ fn;
1844
+
1845
+ constructor(items, fn) {
1846
+ this.items = items;
1847
+ this.fn = fn;
1848
+ }
1849
+
1850
+ /**
1851
+ * Yield the Templates, building each one as it goes, so that code written against the older
1852
+ * array-returning h.map() — spreading it, iterating it, passing it to Array.from — still
1853
+ * works. Doing so builds every row, which is exactly the work the reconciler skips when the
1854
+ * list is handed to it whole, so prefer putting an h.map() straight into a template. */
1855
+ *[Symbol.iterator]() {
1856
+ let items = this.items, fn = this.fn;
1857
+ for (let i=0; i<items.length; i++)
1858
+ yield fn(items[i]);
1859
+ }
1860
+ }
1861
+
1620
1862
  class PathToNodes extends Path {
1621
1863
 
1864
+ /** @type {boolean} True once any NodeGroup this path created needs a visit even when its
1865
+ * values are unchanged (it holds a component or a live HTML property). Those rows are the
1866
+ * reason the list scans exist, so their presence rules out applyMisses()' skip-the-scan
1867
+ * path. Sticky: it's never cleared, which can only cost a scan that wasn't needed. */
1868
+ anyNeedsRefresh = false;
1869
+
1870
+ /** @type {?Array} The h.map() items the previous render drew, one per NodeGroup and in the
1871
+ * same order, so an unchanged row is recognized by comparing two arrays rather than by
1872
+ * following a pointer into each NodeGroup. A thousand rows' NodeGroups are scattered over
1873
+ * a hundred kilobytes, so reading a field from each one costs a cache miss apiece; two flat
1874
+ * arrays walk in step. Null whenever the last render wasn't an h.map().
1875
+ * @type {?Array} */
1876
+ lastItems = null;
1877
+
1878
+ /** @type {boolean} True when the previous render's items contained raw DOM Nodes,
1879
+ * which routes applySingle() to the generic reconciler. Declared so the hot
1880
+ * `!this.itemsHaveNodes` check reads a real field instead of a missing property,
1881
+ * and so the first raw-Node render doesn't transition the hidden class. */
1882
+ itemsHaveNodes = false;
1883
+
1622
1884
  /** @type {?NodeGroup[]} The NodeGroups created by this path's expression, in order.
1623
1885
  * Lazily created; null when the path has only ever rendered a primitive (see textNode). */
1624
1886
  nodeGroups = null;
@@ -1632,14 +1894,6 @@ class PathToNodes extends Path {
1632
1894
 
1633
1895
 
1634
1896
 
1635
- /**
1636
- * Nodes that have been used during the current render().
1637
- * Used with getNodeGroup() and freeNodeGroups() on the generic path; the positional diff
1638
- * tracks in-use NodeGroups in this.nodeGroups instead.
1639
- * Lazily created since most paths never use it.
1640
- * @type {?NodeGroup[]} */
1641
- nodeGroupsRendered = null;
1642
-
1643
1897
  /**
1644
1898
  * Nodes that were added to the web component during the last render(), but are available to be used again.
1645
1899
  * Used with getNodeGroup() and freeNodeGroups(), keyed by close key.
@@ -1657,18 +1911,6 @@ class PathToNodes extends Path {
1657
1911
  super(nodeBefore, nodeMarker);
1658
1912
  }
1659
1913
 
1660
- /**
1661
- * Insert/replace the nodes created by a single expression.
1662
- * Called by applyExprs()
1663
- * @param exprs {Expr[]} Only the first is used.
1664
- * @return {Node[]} New Nodes created. */
1665
- apply(exprs) {
1666
- //#IFDEV
1667
- assert(Array.isArray(exprs));
1668
- //#ENDIF
1669
- this.applySingle(exprs[0]);
1670
- }
1671
-
1672
1914
  /**
1673
1915
  * Make the DOM between nodeBefore and nodeMarker match the value of expr.
1674
1916
  * This is the main entry point for rendering an expression's nodes, chosen from three strategies:
@@ -1680,7 +1922,7 @@ class PathToNodes extends Path {
1680
1922
  * @param expr {Expr} */
1681
1923
  applySingle(expr) {
1682
1924
 
1683
- /*#IFDEV*/this.verify();/*#ENDIF*/
1925
+ /*#IFDEBUG*/this.verify();/*#ENDIF*/
1684
1926
 
1685
1927
  // Fast path for a single primitive expression, the most common case in loops.
1686
1928
  let exprType = typeof expr;
@@ -1756,31 +1998,452 @@ class PathToNodes extends Path {
1756
1998
  this.textNode = null;
1757
1999
  }
1758
2000
 
1759
- // 1. Flatten the expression to a list of Templates, strings and Nodes, evaluating functions along the way.
2001
+ // A selection binding only knows how to write an attribute, so catch it here rather than
2002
+ // letting it render as an empty string and leave the caller wondering where it went.
2003
+ if (expr instanceof SelectorRef)
2004
+ throw new Error('Solarite: a selector must own the whole attribute.');
2005
+
2006
+ // 1. h.map() hands over its source items and callback rather than built Templates, so a
2007
+ // row whose item is unchanged is recognized without building or looking up a Template.
2008
+ if (expr instanceof MappedList) {
2009
+ this.applyMapped(expr);
2010
+ /*#IFDEBUG*/this.verify();/*#ENDIF*/
2011
+ return;
2012
+ }
2013
+
2014
+ // Anything that isn't an h.map() leaves no items to recognize rows by next time.
2015
+ this.lastItems = null;
2016
+
2017
+ // 2. Flatten the expression to a list of Templates, strings and Nodes, evaluating functions along the way.
2018
+ // A flat array that is entirely Templates — the rows.map(...) shape that list renders
2019
+ // produce — is borrowed directly instead of copied. The borrow lasts only for the
2020
+ // rest of this synchronous call: applyDiff/applyKeyed/applyGeneric read the items and
2021
+ // retain only the NodeGroups (and each item's own Template) built from them, never the
2022
+ // items array itself, so no reference to the caller's array survives the render. Keep
2023
+ // that invariant — storing newItems on any long-lived object would pin the caller's
2024
+ // per-render array until the next render, moving its collection into a later frame.
1760
2025
  /** @type {(Template|string|Node)[]} */
1761
- let newItems = [];
1762
- let hasNodesNow = this.collectItems(expr, newItems, false);
2026
+ let newItems = null;
2027
+ let hasNodesNow = false;
2028
+ if (Array.isArray(expr)) {
2029
+ let len = expr.length, i = 0;
2030
+ while (i < len && expr[i] instanceof Template)
2031
+ i++;
2032
+ if (i === len)
2033
+ newItems = expr; // Borrowed from the caller; read-only from here on.
2034
+ }
2035
+ if (newItems === null) {
2036
+ newItems = [];
2037
+ hasNodesNow = this.collectItems(expr, newItems, false);
2038
+ }
1763
2039
 
1764
- // 2. Raw Nodes in the items (now or on the previous render) can't be diffed positionally
2040
+ // 3. Raw Nodes in the items (now or on the previous render) can't be diffed positionally
1765
2041
  // because this.nodeGroups only tracks NodeGroups. Use the generic path for those.
1766
2042
  if (hasNodesNow || this.itemsHaveNodes) {
1767
2043
  this.itemsHaveNodes = hasNodesNow;
1768
2044
  this.applyGeneric(newItems);
1769
2045
  }
1770
- else {
1771
- // Templates with a key=${} attribute diff by key so node identity follows the data.
1772
- // An empty list also routes to applyKeyed when the previous render was keyed,
1773
- // so removed keyed NodeGroups are discarded instead of pooled.
1774
- let first = newItems.length !== 0 ? newItems[0] : null;
1775
- if (first !== null
1776
- ? (typeof first !== 'string' && (first.key !== undefined || Shell.get(first.html, first.svgMode).keyIndex >= 0))
1777
- : (this.nodeGroups !== null && this.nodeGroups.length !== 0 && this.nodeGroups[0].key !== undefined))
1778
- this.applyKeyed(newItems);
2046
+ else
2047
+ this.diffItems(newItems);
2048
+
2049
+ /*#IFDEBUG*/this.verify();/*#ENDIF*/
2050
+ }
2051
+
2052
+ /**
2053
+ * Reconcile a flat list of Templates and strings against this path's NodeGroups.
2054
+ * Templates with a key=${} attribute diff by key so node identity follows the data.
2055
+ * An empty list also routes to applyKeyed when the previous render was keyed, so removed
2056
+ * keyed NodeGroups are discarded instead of pooled.
2057
+ * @param newItems {(Template|string)[]} */
2058
+ diffItems(newItems) {
2059
+ let first = newItems.length !== 0 ? newItems[0] : null;
2060
+ if (first !== null
2061
+ ? (typeof first !== 'string' && (first.key !== undefined || Shell.get(first.html, first.svgMode).keyIndex >= 0))
2062
+ : (this.nodeGroups !== null && this.nodeGroups.length !== 0 && this.nodeGroups[0].key !== undefined))
2063
+ this.applyKeyed(newItems);
2064
+ else
2065
+ this.applyDiff(newItems);
2066
+ }
2067
+
2068
+ /**
2069
+ * Render an h.map() list.
2070
+ *
2071
+ * What makes this cheaper than reconciling an array of Templates is that a row still holding
2072
+ * the item it was built from needs no Template at all: it is recognized by one identity
2073
+ * check, with nothing built and nothing compared. When the list is the same length and only
2074
+ * a few rows changed, that is the whole render — see applyMisses(). Otherwise the walk
2075
+ * follows the offset a shifted list settles on, and finally consults a map from item to the
2076
+ * Template the previous render built, so rows that moved far are still reused.
2077
+ * @param mapped {MappedList} */
2078
+ applyMapped(mapped) {
2079
+ let items = mapped.items, fn = mapped.fn;
2080
+ let len = items.length;
2081
+ let oldNgs = this.nodeGroups;
2082
+ // Only rows this path drew from an h.map() last time can be recognized by their item;
2083
+ // anything else starts over.
2084
+ let lastItems = this.lastItems;
2085
+ let oldLen = oldNgs === null || lastItems === null || lastItems.length !== oldNgs.length
2086
+ ? 0 : oldNgs.length;
2087
+
2088
+ // Patch path. When the list is the same length as last time, every row that still holds
2089
+ // the item it was built from is already final: it needs no Template, no comparison and no
2090
+ // visit. So find the positions that did change, build only those, and patch them. That
2091
+ // makes a selection or a partial update cost work proportional to the change instead of
2092
+ // to the length of the list. Rows that must be visited even when unchanged (components,
2093
+ // live HTML properties) rule it out, since revisiting them is what the full scan is for.
2094
+ let misses = null, missTemplates = null, missCount = 0;
2095
+ if (oldLen === len && len !== 0 && !this.anyNeedsRefresh && !this.itemsHaveNodes) {
2096
+ let tooMany = false;
2097
+ let cap = missProbeThreshold;
2098
+
2099
+ // First find WHICH positions changed, without building anything for them. A change
2100
+ // this path can't handle is then abandoned having cost only comparisons — building
2101
+ // as we went would throw away a Template for every row of, say, a reversed list,
2102
+ // which the general diff is about to reuse from the previous render.
2103
+ for (let i=0; i<len; i++) {
2104
+ if (lastItems[i] !== items[i]) {
2105
+ if (missCount === cap) {
2106
+ // Enough of the list has changed to ask what kind of change this is,
2107
+ // because the two kinds want opposite treatment. If the item at this
2108
+ // position is somewhere else in the old list, the rows were reordered,
2109
+ // and the general diff's item map will reuse their Templates instead of
2110
+ // rebuilding them — so stop here and let it. If the item is new, the
2111
+ // rows' contents changed, and there is nothing to reuse: keep going and
2112
+ // patch them all, however many there are. The scan costs one pass over
2113
+ // the old rows, once, and only for a list that changed this much.
2114
+ if (itemIsElsewhere(lastItems, oldLen, items[i])) {
2115
+ tooMany = true;
2116
+ missCount = 0; // Nothing was built, so the general path has nothing to reuse.
2117
+ break;
2118
+ }
2119
+ cap = len; // Asked and answered; there is no second probe.
2120
+ }
2121
+ (misses ??= [])[missCount++] = i;
2122
+ }
2123
+ }
2124
+
2125
+ // Now build them.
2126
+ if (!tooMany && missCount !== 0) {
2127
+ missTemplates = new Array(missCount);
2128
+ for (let k=0; k<missCount; k++) {
2129
+ let t = fn(items[misses[k]]);
2130
+ if (!(t instanceof Template) && typeof t !== 'string') { // A Node, an array, …
2131
+ tooMany = true;
2132
+ missCount = k; // Keep the ones already built; the rest are the caller's problem.
2133
+ break;
2134
+ }
2135
+ missTemplates[k] = t;
2136
+ }
2137
+ }
2138
+ if (!tooMany && (missCount === 0
2139
+ || this.applyMisses(oldNgs, misses, missTemplates, missCount, len))) {
2140
+ for (let k=0; k<missCount; k++) {
2141
+ let j = misses[k];
2142
+ lastItems[j] = items[j];
2143
+ }
2144
+ return;
2145
+ }
2146
+ }
2147
+
2148
+ // General path: build the whole list of Templates and hand it to the reconciler.
2149
+ let newItems = new Array(len);
2150
+ let built = missCount !== 0 ? misses : null, b = 0;
2151
+ let itemMap = null, noItemMap = false;
2152
+ const indexOfItem = item => {
2153
+ if (noItemMap)
2154
+ return -1;
2155
+ if (itemMap === null) {
2156
+ // One scan before paying for a map: if this item is nowhere in the old rows, the
2157
+ // list's contents changed rather than moved, so there is nothing to look up and
2158
+ // every later miss can go straight to the callback. A scan is cheaper than a map
2159
+ // of every row, and this is the common shape — rows replaced in place.
2160
+ if (!itemIsElsewhere(lastItems, oldLen, item)) {
2161
+ noItemMap = true;
2162
+ return -1;
2163
+ }
2164
+ itemMap = new Map();
2165
+ for (let k=0; k<oldLen; k++)
2166
+ itemMap.set(lastItems[k], k);
2167
+ }
2168
+ let k = itemMap.get(item);
2169
+ return k === undefined ? -1 : k;
2170
+ };
2171
+ // Walk the two lists together. A row is recognized by the item it was built from, at the
2172
+ // offset the walk has settled on: after an insertion or a removal every later row sits a
2173
+ // fixed distance from where it was, and following that keeps recognizing them instead of
2174
+ // treating the whole tail as changed. The short search that re-establishes the offset
2175
+ // only runs while the walk is still in step, so a list of genuinely new rows (an append,
2176
+ // a replace-all) gives up after one miss rather than searching for every row. Failing
2177
+ // all that, a map from item to the Template the previous render built for it catches
2178
+ // rows that moved far — a sort, a shuffle. It's built on demand, from the rows this
2179
+ // path already holds: a persistent per-item cache would instead pay a write for every
2180
+ // row of every list ever created, which is most of the work of building a list from
2181
+ // scratch, and would hold each Template alive for as long as the caller holds the item.
2182
+ if (oldLen !== 0) {
2183
+ let delta = 0, inSync = true;
2184
+ for (let i=0; i<len; i++) {
2185
+ let item = items[i];
2186
+ let j = i + delta;
2187
+ let inRange = j >= 0 && j < oldLen;
2188
+ if (inRange && lastItems[j] === item) {
2189
+ newItems[i] = oldNgs[j].template;
2190
+ inSync = true;
2191
+ continue;
2192
+ }
2193
+
2194
+ // This position was already found to have changed, and its Template built, by the
2195
+ // patch scan above. That only happens for a same-length list, where the offset
2196
+ // stays zero, so there's no search to redo here.
2197
+ if (built !== null && b < missCount && built[b] === i) {
2198
+ newItems[i] = missTemplates[b++];
2199
+ continue;
2200
+ }
2201
+
2202
+ if (inSync) {
2203
+ let found = -1;
2204
+ for (let d=1; d<=shiftSearchDistance; d++) {
2205
+ let after = j + d, before = j - d;
2206
+ if (after < oldLen && lastItems[after] === item) {
2207
+ found = after;
2208
+ break;
2209
+ }
2210
+ if (before >= 0 && lastItems[before] === item) {
2211
+ found = before;
2212
+ break;
2213
+ }
2214
+ }
2215
+ if (found >= 0) {
2216
+ delta = found - i;
2217
+ newItems[i] = oldNgs[found].template;
2218
+ continue;
2219
+ }
2220
+
2221
+ // The item isn't in the old list at all, but the old row standing here
2222
+ // belongs to an item a little further along: rows were INSERTED here. Build
2223
+ // this one and shift the offset, so the rest of the list is still recognized.
2224
+ // Without this, prepending one row to a long list would look like a change to
2225
+ // every row in it. Only worth asking when the list actually grew.
2226
+ if (inRange && len > oldLen)
2227
+ for (let d=1; d<=insertSearchDistance && i+d<len; d++)
2228
+ if (items[i+d] === lastItems[j]) {
2229
+ newItems[i] = fn(item);
2230
+ delta--;
2231
+ found = -2; // Handled; skip the fallbacks below.
2232
+ break;
2233
+ }
2234
+ if (found === -2)
2235
+ continue;
2236
+
2237
+ inSync = false;
2238
+ }
2239
+
2240
+ // Past the end of the old list there is nothing left to match, so appended rows
2241
+ // go straight to the callback instead of paying for a lookup that must miss.
2242
+ if (j < oldLen) {
2243
+ let k = indexOfItem(item);
2244
+ if (k >= 0) {
2245
+ newItems[i] = oldNgs[k].template;
2246
+ delta = k - i; // Back in step; the rest of the list can walk positionally again.
2247
+ inSync = true;
2248
+ continue;
2249
+ }
2250
+ }
2251
+ newItems[i] = fn(item);
2252
+ }
2253
+ }
2254
+
2255
+ else
2256
+ for (let i=0; i<len; i++)
2257
+ newItems[i] = fn(items[i]);
2258
+
2259
+ // A callback that returns something other than a Template or a string (a raw Node, an
2260
+ // array, a nested list) can't be diffed positionally; flatten it the general way.
2261
+ let first = len !== 0 ? newItems[0] : null;
2262
+ if (first !== null && !(first instanceof Template) && typeof first !== 'string') {
2263
+ let flat = [];
2264
+ let hasNodesNow = this.collectItems(newItems, flat, false);
2265
+ if (hasNodesNow || this.itemsHaveNodes) {
2266
+ this.itemsHaveNodes = hasNodesNow;
2267
+ this.applyGeneric(flat);
2268
+ }
1779
2269
  else
1780
- this.applyDiff(newItems);
2270
+ this.diffItems(flat);
2271
+ return;
1781
2272
  }
1782
2273
 
1783
- /*#IFDEV*/this.verify();/*#ENDIF*/
2274
+ if (this.itemsHaveNodes) {
2275
+ this.itemsHaveNodes = false;
2276
+ this.applyGeneric(newItems);
2277
+ return;
2278
+ }
2279
+
2280
+ this.diffItems(newItems);
2281
+
2282
+ // Remember which item drew each row, so the next render can match them by identity.
2283
+ // The reconciler leaves nodeGroups aligned with newItems, and therefore with items.
2284
+ // The caller's array is copied rather than kept, since the caller mutates it in place.
2285
+ let li = this.lastItems;
2286
+ if (li === null || li.length !== len)
2287
+ li = this.lastItems = new Array(len);
2288
+ for (let j=0; j<len; j++)
2289
+ li[j] = items[j];
2290
+ }
2291
+
2292
+ /**
2293
+ * Patch only the positions an h.map() render changed, leaving every other row alone.
2294
+ *
2295
+ * Every unchanged position already holds the NodeGroup built from that exact item, so it
2296
+ * needs no visit at all; only the changed positions can require a rewrite, a move, or a new
2297
+ * row. Changed positions are handled in two steps, the same shape as the general keyed
2298
+ * diff's small-reorder path: first the ones that kept their key (a row whose data changed
2299
+ * in place), then the leftovers are cross-matched against each other by key so a swap or a
2300
+ * short shuffle moves the fewest node ranges.
2301
+ *
2302
+ * @param ngs {NodeGroup[]} This path's NodeGroups, patched in place.
2303
+ * @param misses {int[]} Positions whose item changed, ascending.
2304
+ * @param templates {(Template|string)[]} The new Template for each of those positions.
2305
+ * @param missCount {int}
2306
+ * @param len {int} Length of the list, for anchoring the last position.
2307
+ * @return {boolean} False when the change doesn't fit this path and the caller must run
2308
+ * the general diff instead; nothing has been modified in that case. */
2309
+ applyMisses(ngs, misses, templates, missCount, len) {
2310
+
2311
+ // Only a keyed list can move rows around safely. An unkeyed one can still be rewritten
2312
+ // in place, which is what the positional diff would do for it anyway.
2313
+ let keyed = ngs[0].key !== undefined;
2314
+
2315
+ // 1. Classify the changed positions without touching anything, so that a change too big
2316
+ // for this path can still be handed to the general diff with nothing half-applied.
2317
+ // A row that kept its key is rewritten where it stands; the rest have to be matched
2318
+ // against each other, and past a handful of those the general diff's map-and-LIS
2319
+ // approach is the better tool.
2320
+ let displaced = null, dCount = 0;
2321
+ for (let k=0; k<missCount; k++) {
2322
+ let ng = ngs[misses[k]], t = templates[k];
2323
+ if (typeof t === 'string' || !itemClose(ng, t) || (keyed && ng.key !== keyOf(t))) {
2324
+ if (!keyed || dCount === maxDisplacedMisses)
2325
+ return false;
2326
+ (displaced ??= [])[dCount++] = k;
2327
+ }
2328
+ }
2329
+
2330
+ // 2. Rewrite the rows that kept their key. displaced holds indexes into misses in
2331
+ // ascending order, so one pointer walks past them.
2332
+ for (let k=0, d=0; k<missCount; k++) {
2333
+ if (d < dCount && displaced[d] === k) {
2334
+ d++;
2335
+ continue;
2336
+ }
2337
+ let ng = ngs[misses[k]], t = templates[k];
2338
+ if (itemSame(ng, t))
2339
+ this.refreshSameItem(ng, t);
2340
+ else
2341
+ this.rewriteNodeGroup(ng, t);
2342
+ }
2343
+ if (dCount === 0)
2344
+ return true;
2345
+
2346
+ // 3. Hand the displaced rows to the shared placer. displaced holds indexes into misses
2347
+ // and templates, so misses is what maps a row to its position in the list.
2348
+ let wholeParent = this.wholeParent;
2349
+ this.placeDisplaced(displaced, misses, ngs, templates, ngs, len,
2350
+ wholeParent ? null : this.nodeMarker,
2351
+ wholeParent ? this.nodeMarker : this.nodeMarker.parentNode);
2352
+
2353
+ // 4. Node membership or order changed, so invalidate caches.
2354
+ if (!this.parentNg.firstApply) {
2355
+ this.nodesCache = null;
2356
+ if (this.parentNg.parentPath)
2357
+ this.parentNg.parentPath.clearNodesCache();
2358
+ }
2359
+
2360
+ // Keep state used by the generic path from going stale.
2361
+ if (this.nodeGroupsAttachedAvailable)
2362
+ this.nodeGroupsAttachedAvailable = null;
2363
+ return true;
2364
+ }
2365
+
2366
+ /**
2367
+ * Settle a handful of rows that moved, appeared or vanished within one window of a list.
2368
+ *
2369
+ * Both small-reorder paths — the h.map() patch in applyMisses and the equal-length window in
2370
+ * applyKeyed — reach the same point: a few positions whose old NodeGroup no longer belongs
2371
+ * where it stands, everything around them already correct. Since every candidate came from
2372
+ * this same window, a swap, a dragged row or a short shuffle finds its partners inside it, so
2373
+ * the rows are cross-matched against each other by key rather than through the general
2374
+ * diff's key map and longest-increasing-subsequence machinery.
2375
+ *
2376
+ * rows holds ascending indexes into items, which is the array each caller already has; when
2377
+ * those indexes are not themselves list positions, positions maps them across. Doing the
2378
+ * indirection here rather than compacting it away in the caller keeps this off the allocation
2379
+ * path: neither caller builds an array it wasn't building already. rows.length is small by
2380
+ * construction (at most maxDisplacedMisses), which is what makes the O(n²) cross-match
2381
+ * cheaper than building a map.
2382
+ *
2383
+ * @param rows {int[]} Ascending indexes of the rows to settle.
2384
+ * @param positions {int[]|null} Maps a row index to its list position, or null when the row
2385
+ * indexes are already positions.
2386
+ * @param oldNgs {NodeGroup[]} Where each position's outgoing NodeGroup is read from.
2387
+ * @param items {(Template|string)[]} The new items, indexed by row index.
2388
+ * @param outNgs {NodeGroup[]} Receives the NodeGroup that ends up at each position. May be
2389
+ * the same array as oldNgs; the outgoing groups are snapshotted before anything is written.
2390
+ * @param boundary {int} First position past this window, where the anchor stops being
2391
+ * outNgs[p+1] and becomes tailAnchor.
2392
+ * @param tailAnchor {Node|null} Anchor for a row placed at boundary-1.
2393
+ * @param parent {Node} Where the rows' nodes live. */
2394
+ placeDisplaced(rows, positions, oldNgs, items, outNgs, boundary, tailAnchor, parent) {
2395
+ let count = rows.length;
2396
+
2397
+ // 1. Cross-match the rows against each other by key. A claimed NodeGroup is nulled out
2398
+ // of the snapshot so it can't be claimed twice.
2399
+ let free = new Array(count);
2400
+ for (let b=0; b<count; b++) {
2401
+ let i = rows[b];
2402
+ free[b] = oldNgs[positions === null ? i : positions[i]];
2403
+ }
2404
+ let placed = new Array(count);
2405
+ for (let a=0; a<count; a++) {
2406
+ let t = items[rows[a]];
2407
+ let key = keyOf(t);
2408
+ if (key !== undefined)
2409
+ for (let b=0; b<count; b++) {
2410
+ let ng = free[b];
2411
+ if (ng !== null && ng.key === key && itemClose(ng, t)) {
2412
+ free[b] = null;
2413
+ if (itemSame(ng, t))
2414
+ this.refreshSameItem(ng, t);
2415
+ else
2416
+ this.rewriteNodeGroup(ng, t);
2417
+ placed[a] = ng;
2418
+ break;
2419
+ }
2420
+ }
2421
+ }
2422
+
2423
+ // 2. Discard the old rows nothing claimed. Keyed semantics require a new key to get new
2424
+ // nodes, so these are never pooled.
2425
+ for (let b=0; b<count; b++) {
2426
+ let ng = free[b];
2427
+ if (ng !== null) {
2428
+ if (ng.startNode !== ng.endNode)
2429
+ Util.saveOrphans(ng.getNodes());
2430
+ else
2431
+ ng.startNode.remove();
2432
+ }
2433
+ }
2434
+
2435
+ // 3. Put the rows in place, right to left so each one's anchor is already final.
2436
+ for (let a=count-1; a>=0; a--) {
2437
+ let i = rows[a];
2438
+ let p = positions === null ? i : positions[i];
2439
+ let ng = placed[a];
2440
+ if (ng === undefined)
2441
+ ng = this.createNew(items[i]);
2442
+ outNgs[p] = ng;
2443
+ let anchor = p+1 < boundary ? outNgs[p+1].startNode : tailAnchor;
2444
+ if (ng.endNode.nextSibling !== anchor || ng.startNode.parentNode !== parent)
2445
+ insertNodesBefore(parent, ng, anchor);
2446
+ }
1784
2447
  }
1785
2448
 
1786
2449
  /**
@@ -1803,8 +2466,8 @@ class PathToNodes extends Path {
1803
2466
  let ng = oldNgs[start], t = newItems[start];
1804
2467
  if (!itemSame(ng, t))
1805
2468
  break;
1806
- if (ng.hasComponentPaths)
1807
- ng.applyExprs(t.exprs, false);
2469
+ if (ng.shell.needsRefresh)
2470
+ this.refreshSameItem(ng, t);
1808
2471
  newNgs[start] = ng;
1809
2472
  start++;
1810
2473
  }
@@ -1814,8 +2477,8 @@ class PathToNodes extends Path {
1814
2477
  let ng = oldNgs[oldEnd-1], t = newItems[newEnd-1];
1815
2478
  if (!itemSame(ng, t))
1816
2479
  break;
1817
- if (ng.hasComponentPaths)
1818
- ng.applyExprs(t.exprs, false);
2480
+ if (ng.shell.needsRefresh)
2481
+ this.refreshSameItem(ng, t);
1819
2482
  newNgs[--newEnd] = ng;
1820
2483
  oldEnd--;
1821
2484
  }
@@ -1824,8 +2487,8 @@ class PathToNodes extends Path {
1824
2487
  while (start < oldEnd && start < newEnd) {
1825
2488
  let ng = oldNgs[start], t = newItems[start];
1826
2489
  if (itemSame(ng, t)) { // Can happen between changed rows, e.g. partial updates.
1827
- if (ng.hasComponentPaths)
1828
- ng.applyExprs(t.exprs, false);
2490
+ if (ng.shell.needsRefresh)
2491
+ this.refreshSameItem(ng, t);
1829
2492
  }
1830
2493
  else if (itemClose(ng, t))
1831
2494
  this.rewriteNodeGroup(ng, t);
@@ -1862,34 +2525,18 @@ class PathToNodes extends Path {
1862
2525
  }
1863
2526
  }
1864
2527
 
1865
- // 5. Insert leftover new items.
2528
+ // 5. Insert leftover new items directly. Each row is one native insert; a
2529
+ // batching DocumentFragment would double the insert count for no benefit,
2530
+ // since style/layout work is deferred until the next frame either way.
1866
2531
  if (newRemain) {
1867
2532
  let wholeParent = this.wholeParent;
1868
2533
  let anchor = newEnd < newLen ? newNgs[newEnd].startNode : (wholeParent ? null : this.nodeMarker);
1869
2534
  let parent = wholeParent ? this.nodeMarker : this.nodeMarker.parentNode;
1870
- let target = parent, before = anchor;
1871
- let fragment = null;
1872
- if (newRemain > 1) { // Batch-insert through a fragment.
1873
- fragment = Globals$1.doc.createDocumentFragment();
1874
- target = fragment;
1875
- before = null;
1876
- }
1877
2535
  for (let i=start; i<newEnd; i++) {
1878
2536
  let ng = this.createOrReuse(newItems[i]);
1879
2537
  newNgs[i] = ng;
1880
- let node = ng.startNode, end = ng.endNode;
1881
- if (node === end) // Single-node NodeGroups are the common case in loops.
1882
- target.insertBefore(node, before);
1883
- else while (true) {
1884
- let next = node.nextSibling;
1885
- target.insertBefore(node, before);
1886
- if (node === end)
1887
- break;
1888
- node = next;
1889
- }
2538
+ insertNodesBefore(parent, ng, anchor);
1890
2539
  }
1891
- if (fragment)
1892
- parent.insertBefore(fragment, anchor);
1893
2540
  }
1894
2541
 
1895
2542
  // 6. Node membership changed, so invalidate caches.
@@ -1904,8 +2551,6 @@ class PathToNodes extends Path {
1904
2551
  this.nodeGroups = newNgs;
1905
2552
 
1906
2553
  // Keep state used by the generic path from going stale.
1907
- if (this.nodeGroupsRendered)
1908
- this.nodeGroupsRendered = null;
1909
2554
  if (this.nodeGroupsAttachedAvailable)
1910
2555
  this.nodeGroupsAttachedAvailable = null;
1911
2556
  }
@@ -1921,26 +2566,14 @@ class PathToNodes extends Path {
1921
2566
  * @param newItems {(Template|string)[]} */
1922
2567
  applyKeyed(newItems) {
1923
2568
  let oldNgs = this.nodeGroups || emptyNodeGroups;
1924
- let oldLen = oldNgs.length, newLen = newItems.length;
1925
- let newNgs = new Array(newLen);
1926
-
1927
- // Resolve an item's key, caching the html->keyIndex lookup for same-template lists.
1928
- let keyHtml = null, keyIndex = -1;
1929
- const keyOf = t => {
1930
- if (t.key !== undefined) // JSX templates carry the key directly.
1931
- return t.key;
1932
- if (t.html !== keyHtml) {
1933
- keyHtml = t.html;
1934
- keyIndex = Shell.get(t.html, t.svgMode).keyIndex;
1935
- }
1936
- return keyIndex >= 0 ? t.exprs[keyIndex] : undefined;
1937
- };
2569
+ let oldLen = oldNgs.length, newLen = newItems.length;
2570
+ let newNgs = new Array(newLen);
1938
2571
 
1939
- //#IFDEV
2572
+ //#IFDEBUG
1940
2573
  {
1941
2574
  let seen = new Set();
1942
2575
  for (let t of newItems) {
1943
- let k = typeof t === 'string' ? undefined : keyOf(t);
2576
+ let k = keyOf(t);
1944
2577
  if (k === undefined)
1945
2578
  console.warn('Unkeyed item in a keyed list; it will be rebuilt on every render:', t);
1946
2579
  else if (seen.has(k))
@@ -1958,15 +2591,13 @@ class PathToNodes extends Path {
1958
2591
  let ng = oldNgs[start], t = newItems[start];
1959
2592
  // An identical Template instance (h.map) implies an identical key, so skip key extraction.
1960
2593
  if (ng.template === t) {
1961
- if (ng.hasComponentPaths)
1962
- ng.applyExprs(t.exprs, false);
2594
+ if (ng.shell.needsRefresh)
2595
+ this.refreshSameItem(ng, t);
1963
2596
  }
1964
2597
  else if (typeof t === 'string' || ng.key !== keyOf(t) || !itemClose(ng, t))
1965
2598
  break;
1966
- else if (itemSame(ng, t)) {
1967
- if (ng.hasComponentPaths)
1968
- ng.applyExprs(t.exprs, false);
1969
- }
2599
+ else if (itemSame(ng, t))
2600
+ this.refreshSameItem(ng, t);
1970
2601
  else
1971
2602
  this.rewriteNodeGroup(ng, t);
1972
2603
  newNgs[start] = ng;
@@ -1977,15 +2608,13 @@ class PathToNodes extends Path {
1977
2608
  while (oldEnd > start && newEnd > start) {
1978
2609
  let ng = oldNgs[oldEnd-1], t = newItems[newEnd-1];
1979
2610
  if (ng.template === t) {
1980
- if (ng.hasComponentPaths)
1981
- ng.applyExprs(t.exprs, false);
2611
+ if (ng.shell.needsRefresh)
2612
+ this.refreshSameItem(ng, t);
1982
2613
  }
1983
2614
  else if (typeof t === 'string' || ng.key !== keyOf(t) || !itemClose(ng, t))
1984
2615
  break;
1985
- else if (itemSame(ng, t)) {
1986
- if (ng.hasComponentPaths)
1987
- ng.applyExprs(t.exprs, false);
1988
- }
2616
+ else if (itemSame(ng, t))
2617
+ this.refreshSameItem(ng, t);
1989
2618
  else
1990
2619
  this.rewriteNodeGroup(ng, t);
1991
2620
  newNgs[--newEnd] = ng;
@@ -1997,6 +2626,57 @@ class PathToNodes extends Path {
1997
2626
  let wholeParent = this.wholeParent;
1998
2627
  let parent = wholeParent ? this.nodeMarker : this.nodeMarker.parentNode;
1999
2628
 
2629
+ // 3a. Equal-length windows: scan them aligned. Rows whose keys match positionally
2630
+ // are updated in place with no bookkeeping, and when at most 8 positions are
2631
+ // displaced (a swap, a dragged row, a small shuffle) they're cross-matched and
2632
+ // moved directly — no key map, no sources array, no LIS. A bigger shuffle falls
2633
+ // through to the general map phase; the in-place updates already done stay valid
2634
+ // there, since the map phase finds those rows already matching their new items.
2635
+ let fastHandled = false;
2636
+ if (oldRemain === newRemain) {
2637
+ let displaced = null;
2638
+ let ok = true;
2639
+ for (let i=start; i<newEnd; i++) {
2640
+ let ng = oldNgs[i], t = newItems[i];
2641
+ if (ng.template === t) {
2642
+ if (ng.shell.needsRefresh)
2643
+ this.refreshSameItem(ng, t);
2644
+ }
2645
+ else {
2646
+ let k = keyOf(t);
2647
+ if (k !== undefined && ng.key === k && itemClose(ng, t)) {
2648
+ if (itemSame(ng, t))
2649
+ this.refreshSameItem(ng, t);
2650
+ else
2651
+ this.rewriteNodeGroup(ng, t);
2652
+ }
2653
+ else {
2654
+ (displaced ??= []).push(i);
2655
+ if (displaced.length > 8) {
2656
+ ok = false;
2657
+ break;
2658
+ }
2659
+ continue; // newNgs[i] is filled during the placement pass below.
2660
+ }
2661
+ }
2662
+ newNgs[i] = ng;
2663
+ }
2664
+ if (ok) {
2665
+ // The windows are the same length, so a displaced row's index is already its
2666
+ // position and no position map is needed. The tail anchor is the suffix row
2667
+ // just past this window, which placement never writes to — it only fills
2668
+ // positions below newEnd — so it is computed once here instead of on every
2669
+ // pass around the placement loop.
2670
+ if (displaced !== null)
2671
+ this.placeDisplaced(displaced, null, oldNgs, newItems, newNgs, newEnd,
2672
+ newEnd < newLen ? newNgs[newEnd].startNode : (wholeParent ? null : this.nodeMarker),
2673
+ parent);
2674
+ fastHandled = true;
2675
+ }
2676
+ }
2677
+
2678
+ if (!fastHandled) {
2679
+
2000
2680
  // 3. Match the middle windows by key.
2001
2681
  let kept = 0, moved = false;
2002
2682
  let sources = null; // sources[i] = old index reused by new item start+i, or -1 to create fresh.
@@ -2022,10 +2702,8 @@ class PathToNodes extends Path {
2022
2702
  moved = true;
2023
2703
  else
2024
2704
  lastNewIndex = newIndex;
2025
- if (itemSame(ng, t)) {
2026
- if (ng.hasComponentPaths)
2027
- ng.applyExprs(t.exprs, false);
2028
- }
2705
+ if (itemSame(ng, t))
2706
+ this.refreshSameItem(ng, t);
2029
2707
  else
2030
2708
  this.rewriteNodeGroup(ng, t);
2031
2709
  newNgs[newIndex] = ng;
@@ -2034,59 +2712,72 @@ class PathToNodes extends Path {
2034
2712
  (removals ??= []).push(ng);
2035
2713
  }
2036
2714
  }
2037
- else {
2038
- removals = oldNgs.slice(start, oldEnd);
2039
- }
2715
+ // else: the whole old window goes away. It isn't collected into an array here,
2716
+ // because the fast clear below usually takes every one of them at once and the
2717
+ // array would be built only to be thrown away.
2718
+ }
2719
+
2720
+ // 3b. A large whole-parent list that is being fully replaced is emptied and refilled
2721
+ // with its parent detached, so the browser's connected-tree bookkeeping (child-change
2722
+ // notifications, tree-version bumps, MutationObserver interest walks, deferred
2723
+ // accessibility and style consumers) runs once at reattach instead of once per row
2724
+ // removed and once per row added. Detaching before the clear, rather than after it,
2725
+ // puts the removals on the cheap side of that line as well. The gates: the whole
2726
+ // region is being replaced, so nothing is kept and no focus can survive inside it;
2727
+ // the parent is a plain element, since detaching a custom element would fire its
2728
+ // disconnected/connectedCallback in the middle of a render and a subclass may run
2729
+ // arbitrary logic there; the parent is in the document, since the notification storm
2730
+ // only exists on a connected tree; and the list is long enough for the saving to beat
2731
+ // the fixed cost of the detour and the extra MutationObserver records it creates.
2732
+ let detachedFrom = null, reattachBefore = null;
2733
+ if (wholeParent && start === 0 && newEnd === newLen && kept === 0 && newRemain > 500
2734
+ && parent.isConnected && parent.parentNode !== null
2735
+ && parent.localName.indexOf('-') === -1 && !parent.hasAttribute('is')) {
2736
+ detachedFrom = parent.parentNode;
2737
+ reattachBefore = parent.nextSibling;
2738
+ parent.remove();
2040
2739
  }
2041
2740
 
2042
2741
  // 4. Remove unmatched old NodeGroups. They're discarded, never pooled,
2043
2742
  // so a later render with new keys always creates new nodes.
2044
- if (removals) {
2045
- // Materialize node caches of multi-node groups while attached, since detaching breaks sibling links.
2046
- for (let ng of removals)
2047
- if (ng.startNode !== ng.endNode)
2048
- ng.getNodes();
2743
+ let removeAll = oldRemain !== 0 && newRemain === 0;
2744
+ if (removals !== null || removeAll) {
2745
+ // Fast clear when nothing is kept anywhere; the whole region is removals. Trying
2746
+ // it first means a cleared list skips the two passes below entirely: those exist
2747
+ // to lift each group's nodes out one at a time, and emptying the parent has
2748
+ // already taken all of them.
2749
+ if (!(start === 0 && newEnd === newLen && kept === 0 && this.fastClear())) {
2750
+ if (removeAll)
2751
+ removals = oldNgs.slice(start, oldEnd);
2752
+
2753
+ // Materialize node caches of multi-node groups while attached, since detaching breaks sibling links.
2754
+ for (let ng of removals)
2755
+ if (ng.startNode !== ng.endNode)
2756
+ ng.getNodes();
2049
2757
 
2050
- // Fast clear when nothing is kept anywhere; the whole region is removals.
2051
- let cleared = start === 0 && newEnd === newLen && kept === 0 && this.fastClear();
2052
- if (!cleared)
2053
2758
  for (let ng of removals) {
2054
2759
  if (ng.startNode !== ng.endNode)
2055
2760
  Util.saveOrphans(ng.getNodes()); // Moves the nodes out of the DOM, into their own fragment.
2056
2761
  else
2057
2762
  ng.startNode.remove();
2058
2763
  }
2764
+ }
2059
2765
  }
2060
2766
 
2061
2767
  // 5. Insert new NodeGroups and move kept ones.
2062
2768
  if (newRemain) {
2063
2769
  let anchor = newEnd < newLen ? newNgs[newEnd].startNode : (wholeParent ? null : this.nodeMarker);
2064
2770
 
2065
- // 5a. Nothing kept in the middle: batch-insert every new item through a fragment.
2771
+ // 5a. Nothing kept in the middle: insert every new item directly.
2772
+ // Each row is one native insert; routing rows through a batching
2773
+ // DocumentFragment would double the insert count for no benefit, since
2774
+ // style/layout work is deferred until the next frame either way.
2066
2775
  if (kept === 0) {
2067
- let target = parent, before = anchor;
2068
- let fragment = null;
2069
- if (newRemain > 1) {
2070
- fragment = Globals$1.doc.createDocumentFragment();
2071
- target = fragment;
2072
- before = null;
2073
- }
2074
2776
  for (let i=start; i<newEnd; i++) {
2075
2777
  let ng = this.createNew(newItems[i]);
2076
2778
  newNgs[i] = ng;
2077
- let node = ng.startNode, end = ng.endNode;
2078
- if (node === end)
2079
- target.insertBefore(node, before);
2080
- else while (true) {
2081
- let next = node.nextSibling;
2082
- target.insertBefore(node, before);
2083
- if (node === end)
2084
- break;
2085
- node = next;
2086
- }
2779
+ insertNodesBefore(parent, ng, anchor);
2087
2780
  }
2088
- if (fragment)
2089
- parent.insertBefore(fragment, anchor);
2090
2781
  }
2091
2782
 
2092
2783
  // 5b. Mixed: iterate backwards so each item's anchor is already in place.
@@ -2113,6 +2804,11 @@ class PathToNodes extends Path {
2113
2804
  }
2114
2805
  }
2115
2806
 
2807
+ if (detachedFrom !== null)
2808
+ detachedFrom.insertBefore(parent, reattachBefore);
2809
+
2810
+ } // end if (!fastHandled)
2811
+
2116
2812
  // 6. Node membership or order changed, so invalidate caches.
2117
2813
  // During a NodeGroup's first applyExprs(), no ancestor caches can reference its nodes yet.
2118
2814
  if (!this.parentNg.firstApply) {
@@ -2125,8 +2821,6 @@ class PathToNodes extends Path {
2125
2821
  this.nodeGroups = newNgs;
2126
2822
 
2127
2823
  // Keep state used by the generic path from going stale.
2128
- if (this.nodeGroupsRendered)
2129
- this.nodeGroupsRendered = null;
2130
2824
  if (this.nodeGroupsAttachedAvailable)
2131
2825
  this.nodeGroupsAttachedAvailable = null;
2132
2826
  }
@@ -2140,11 +2834,30 @@ class PathToNodes extends Path {
2140
2834
  if (typeof item === 'string')
2141
2835
  return new NodeGroup(textTemplate(item), this); // Text NodeGroups have no paths to apply.
2142
2836
  let ng = new NodeGroup(item, this);
2837
+ if (ng.shell.needsRefresh)
2838
+ this.anyNeedsRefresh = true;
2143
2839
  if (item.exprs.length || (ng.paths && ng.paths.length))
2144
2840
  ng.applyExprs(item.exprs);
2145
2841
  return ng;
2146
2842
  }
2147
2843
 
2844
+ /**
2845
+ * Refresh a NodeGroup whose new template has the SAME values as its current one.
2846
+ * Components still render so changes deeper in the tree can surface, and groups holding
2847
+ * live-HTML-property bindings (checked/value/selected) rewrite in place — a user's click
2848
+ * flips those DOM properties underneath the cached expression, so same values ≠ same DOM.
2849
+ * rewriteNodeGroup's per-path skip exempts exactly those paths; everything else is
2850
+ * compared and skipped as before, so this stays cheap.
2851
+ * @param ng {NodeGroup}
2852
+ * @param t {Template|string} */
2853
+ refreshSameItem(ng, t) {
2854
+ let shell = ng.shell;
2855
+ if (shell.hasComponentPaths)
2856
+ ng.applyExprs(t.exprs, false);
2857
+ else if (shell.hasLivePropPaths && shell.pathsSingleExpr && typeof t !== 'string')
2858
+ this.rewriteNodeGroup(ng, t);
2859
+ }
2860
+
2148
2861
  /**
2149
2862
  * Update an existing NodeGroup, created from the same html strings, with new values.
2150
2863
  * @param ng {NodeGroup}
@@ -2158,15 +2871,21 @@ class PathToNodes extends Path {
2158
2871
  else {
2159
2872
  // When every path consumes exactly one expression, paths align 1:1 with exprs,
2160
2873
  // so only the expressions that changed need to be applied.
2161
- if (ng.pathsSingleExpr) {
2874
+ if (ng.shell.pathsSingleExpr) {
2162
2875
  // Stamped groups (paths === null) rewrite through the shared stampers and stay
2163
2876
  // path-less, unless a child-node expression stopped being primitive.
2164
2877
  if (ng.paths !== null || !ng.rewriteStamp(item)) {
2165
2878
  let oldExprs = ng.template.exprs, newExprs = item.exprs;
2166
2879
  let paths = ng.paths ?? ng.materializePaths();
2167
- for (let i = paths.length - 1; i >= 0; i--)
2168
- if (!exprSame(oldExprs[i], newExprs[i]))
2169
- paths[i].applySingle(newExprs[i]);
2880
+ for (let i = paths.length - 1; i >= 0; i--) {
2881
+ // Boolean live-HTML-property bindings are exempt from the unchanged-value
2882
+ // skip — a click flips the property underneath the cached expression;
2883
+ // applySingle() compares against the live node before writing.
2884
+ let oldExpr = oldExprs[i], newExpr = newExprs[i];
2885
+ if ((oldExpr !== newExpr && !exprSame(oldExpr, newExpr))
2886
+ || (paths[i].isHtmlProperty && typeof newExpr === 'boolean'))
2887
+ paths[i].applySingle(newExpr);
2888
+ }
2170
2889
  }
2171
2890
 
2172
2891
  if (ng.styles)
@@ -2205,6 +2924,8 @@ class PathToNodes extends Path {
2205
2924
  }
2206
2925
 
2207
2926
  ng = new NodeGroup(item, this);
2927
+ if (ng.shell.needsRefresh)
2928
+ this.anyNeedsRefresh = true;
2208
2929
  if (item.exprs.length || (ng.paths && ng.paths.length))
2209
2930
  ng.applyExprs(item.exprs);
2210
2931
  return ng;
@@ -2232,6 +2953,14 @@ class PathToNodes extends Path {
2232
2953
  else if (typeof expr === 'function')
2233
2954
  hasNodes = this.collectItems(expr(), items, hasNodes);
2234
2955
 
2956
+ // A MappedList nested inside an array or returned from a function can't use the
2957
+ // identity fast path, but it still renders; expand it through the per-item cache.
2958
+ else if (expr instanceof MappedList) {
2959
+ let subItems = expr.items, fn = expr.fn;
2960
+ for (let i=0; i<subItems.length; i++)
2961
+ items.push(fn(subItems[i]));
2962
+ }
2963
+
2235
2964
  else if (expr instanceof NodeList) {
2236
2965
  for (let node of expr)
2237
2966
  items.push(node);
@@ -2271,7 +3000,7 @@ class PathToNodes extends Path {
2271
3000
  /** @type {Node[]} */
2272
3001
  let newNodes = [];
2273
3002
  let oldNodeGroups = path.nodeGroups || emptyNodeGroups;
2274
- /*#IFDEV*/assert(!oldNodeGroups.includes(null));/*#ENDIF*/
3003
+ /*#IFDEBUG*/assert(!oldNodeGroups.includes(null));/*#ENDIF*/
2275
3004
 
2276
3005
  path.nodeGroups = [];
2277
3006
  for (let item of items) {
@@ -2379,29 +3108,26 @@ class PathToNodes extends Path {
2379
3108
  || this.nodeGroupsDetachedAvailable?.deleteAny(closeKey);
2380
3109
 
2381
3110
  if (result) {
2382
- if (templatesSame(result.template, template)) {
2383
- // Components still render so changes deeper in the tree can surface.
2384
- if (result.hasComponentPaths)
2385
- result.applyExprs(template.exprs, false);
2386
- }
3111
+ if (templatesSame(result.template, template))
3112
+ this.refreshSameItem(result, template);
2387
3113
  else
2388
3114
  result.applyExprs(template.exprs);
2389
3115
  result.template = template;
2390
3116
  }
2391
3117
  else {
2392
3118
  result = new NodeGroup(template, this);
3119
+ if (result.shell.needsRefresh)
3120
+ this.anyNeedsRefresh = true;
2393
3121
  result.applyExprs(template.exprs);
2394
3122
  }
2395
3123
 
2396
- (this.nodeGroupsRendered ??= []).push(result);
2397
-
2398
- /*#IFDEV*/assert(result.parentPath);/*#ENDIF*/
3124
+ /*#IFDEBUG*/assert(result.parentPath);/*#ENDIF*/
2399
3125
  return result;
2400
3126
  }
2401
3127
 
2402
3128
 
2403
3129
  /**
2404
- * Move everything from this.nodeGroupsRendered to this.nodeGroupsAttached and nodeGroupsDetached.
3130
+ * Move everything from this.nodeGroups to this.nodeGroupsAttached and nodeGroupsDetached.
2405
3131
  * Called at the beginning of applyGeneric() so it can have NodeGroups to use.
2406
3132
  * TODO: this could run as needed in getNodeGroup? */
2407
3133
  freeNodeGroups() {
@@ -2411,7 +3137,7 @@ class PathToNodes extends Path {
2411
3137
  let detached = (this.nodeGroupsDetachedAvailable ??= new MultiValueMap()).data;
2412
3138
  for (let key in previouslyAttached) {
2413
3139
  let src = previouslyAttached[key];
2414
- let from = src.head || 0; // Skip entries already consumed by deleteAny().
3140
+ let from = src.hd || 0; // Skip entries already consumed by deleteAny().
2415
3141
  let array = detached[key];
2416
3142
  if (!array) {
2417
3143
  array = detached[key] = from ? src.slice(from) : src;
@@ -2419,22 +3145,18 @@ class PathToNodes extends Path {
2419
3145
  array.length = maxPooledPerKey;
2420
3146
  }
2421
3147
  else
2422
- for (let i=from, max=maxPooledPerKey + (array.head || 0); i<src.length && array.length < max; i++)
3148
+ for (let i=from, max=maxPooledPerKey + (array.hd || 0); i<src.length && array.length < max; i++)
2423
3149
  array.push(src[i]);
2424
3150
  }
2425
3151
  }
2426
3152
 
2427
- // Add nodes that were used during render() to nodeGroupsRendered.
2428
- // If the last render used the positional diff, the in-use NodeGroups are in
2429
- // this.nodeGroups instead of nodeGroupsRendered.
2430
- this.nodeGroupsAttachedAvailable = new MultiValueMap();
2431
- let nga = this.nodeGroupsAttachedAvailable;
2432
- let source = this.nodeGroupsRendered?.length ? this.nodeGroupsRendered : this.nodeGroups;
2433
- if (source)
2434
- for (let ng of source)
3153
+ // Offer the NodeGroups the last render left in place for reuse. Every path that renders
3154
+ // NodeGroups the positional diff, the keyed diff and applyGeneric alike — leaves them in
3155
+ // this.nodeGroups, so that one array is always the set still standing in the DOM.
3156
+ let nga = this.nodeGroupsAttachedAvailable = new MultiValueMap();
3157
+ if (this.nodeGroups)
3158
+ for (let ng of this.nodeGroups)
2435
3159
  nga.add(ng.closeKey, ng);
2436
-
2437
- this.nodeGroupsRendered = null;
2438
3160
  }
2439
3161
 
2440
3162
 
@@ -2454,7 +3176,7 @@ class PathToNodes extends Path {
2454
3176
  // This shaves about 5ms off the partialUpdate benchmark.
2455
3177
  result = this.nodesCache;
2456
3178
  if (result) {
2457
- //#IFDEV
3179
+ //#IFDEBUG
2458
3180
  //this.checkNodesCache();
2459
3181
  //#ENDIF
2460
3182
  return result
@@ -2477,7 +3199,7 @@ class PathToNodes extends Path {
2477
3199
  return result;
2478
3200
  }
2479
3201
 
2480
- //#IFDEV
3202
+ //#IFDEBUG
2481
3203
 
2482
3204
  get debug() {
2483
3205
  return [
@@ -2511,12 +3233,55 @@ class PathToNodes extends Path {
2511
3233
  // Shared empty array for paths whose nodeGroups were never created. Never mutated.
2512
3234
  const emptyNodeGroups = [];
2513
3235
 
3236
+ // How many changed h.map() positions applyMapped() collects before it stops to work out what
3237
+ // kind of change it is looking at (see the probe in applyMapped). Below this every ordinary
3238
+ // edit — a selection, a partial update — is handled without asking.
3239
+ const missProbeThreshold = 256;
3240
+
3241
+ // How many of those positions may need matching against each other before the general keyed
3242
+ // diff, with its key map and longest-increasing-subsequence, becomes the cheaper tool. The
3243
+ // cross-match here is quadratic, which only pays while the number of moved rows is small.
3244
+ const maxDisplacedMisses = 16;
3245
+
3246
+ // How far applyMapped() looks around a position to pick a shifted list's rows back up. One
3247
+ // insertion or removal moves everything by one, which the first step finds; a handful at once
3248
+ // still lands inside this window, and past it the item map takes over.
3249
+ const shiftSearchDistance = 4;
3250
+
3251
+ // How far ahead it looks to recognize a block of inserted rows, by finding the item that the
3252
+ // old row standing here now belongs to. Wider than the search above because inserting a page
3253
+ // of rows at once is ordinary, and because this search only runs while the walk is still in
3254
+ // step and stops it dead the first time it fails — so its worst case is one pass of this many
3255
+ // comparisons per render, against building a map of every row in the list.
3256
+ const insertSearchDistance = 64;
3257
+
2514
3258
  // Most detached NodeGroups kept per close key. Bounds memory growth after very large
2515
3259
  // lists are cleared while keeping pooled rows for every typical re-create pattern.
2516
3260
  // Lowering this (e.g. to 1000) cuts retained memory ~7x after clearing a 10k-row list,
2517
3261
  // but makes re-creating such a list ~2x slower since most rows are built fresh.
2518
3262
  const maxPooledPerKey = 10000;
2519
3263
 
3264
+
3265
+ // Cache for keyOf(): list rows share one html array, so the Shell lookup that finds where the
3266
+ // key=${} expression sits happens once per list rather than once per row.
3267
+ let lastKeyHtml = null, lastKeyIndex = -1;
3268
+
3269
+ /**
3270
+ * The list key of an item, or undefined when it has none.
3271
+ * @param t {Template|string}
3272
+ * @return {*} */
3273
+ function keyOf(t) {
3274
+ if (typeof t === 'string')
3275
+ return undefined;
3276
+ if (t.key !== undefined) // JSX templates carry the key directly.
3277
+ return t.key;
3278
+ if (t.html !== lastKeyHtml) {
3279
+ lastKeyHtml = t.html;
3280
+ lastKeyIndex = Shell.get(t.html, t.svgMode).keyIndex;
3281
+ }
3282
+ return lastKeyIndex >= 0 ? t.exprs[lastKeyIndex] : undefined;
3283
+ }
3284
+
2520
3285
  /**
2521
3286
  * @param text {string}
2522
3287
  * @return {Template} */
@@ -2553,6 +3318,21 @@ function itemClose(ng, item) {
2553
3318
  return tpl.html === item.html && tpl.svgMode === item.svgMode;
2554
3319
  }
2555
3320
 
3321
+ /**
3322
+ * Is this item somewhere in the list the previous render drew, i.e. did it move rather than
3323
+ * appear? A plain scan rather than a map, because it runs once and usually answers on the way
3324
+ * past.
3325
+ * @param lastItems {Array}
3326
+ * @param oldLen {int}
3327
+ * @param item {*}
3328
+ * @return {boolean} */
3329
+ function itemIsElsewhere(lastItems, oldLen, item) {
3330
+ for (let i=0; i<oldLen; i++)
3331
+ if (lastItems[i] === item)
3332
+ return true;
3333
+ return false;
3334
+ }
3335
+
2556
3336
  /**
2557
3337
  * Insert all of ng's nodes before anchor within parent.
2558
3338
  * @param parent {Node}
@@ -2649,12 +3429,6 @@ function reconcileNodes(parentNode, oldNodes, newNodes, before) {
2649
3429
  * matches NodeGroups to new templates by this key. */
2650
3430
  class PathToKey extends Path {
2651
3431
 
2652
- /**
2653
- * @param exprs {Expr[]} Only the first is used. */
2654
- apply(exprs) {
2655
- this.parentNg.key = exprs[0];
2656
- }
2657
-
2658
3432
  applySingle(expr) {
2659
3433
  this.parentNg.key = expr;
2660
3434
  }
@@ -2676,15 +3450,15 @@ class PathToComponent extends Path {
2676
3450
  * Call render() on the component pointed to by this Path.
2677
3451
  * And instantiate it (from a -solarite-placeholder element) if it hasn't been done yet.
2678
3452
  * @param exprs {Expr[][]} Expressions to evaluate for each attribute to pass to the constructor.
2679
- * This is different than other Path.apply() functions which only receive Expr[] and not Expr[][].
3453
+ * This is different than other Path.applyAll() functions which only receive Expr[] and not Expr[][].
2680
3454
  * Because here we're receiving an array of arrays of expressions, one for each dynamic attribute. */
2681
- apply(exprs) {
2682
- //#IFDEV
3455
+ applyAll(exprs) {
3456
+ //#IFDEBUG
2683
3457
  assert(Array.isArray(exprs));
2684
3458
  assert(!exprs.length || Array.isArray(exprs[0]));
2685
3459
  //#ENDIF
2686
3460
 
2687
- //#IFDEV
3461
+ //#IFDEBUG
2688
3462
  assert(exprs.length === this.attribPaths.length);
2689
3463
  //#ENDIF
2690
3464
 
@@ -2700,8 +3474,15 @@ class PathToComponent extends Path {
2700
3474
  for (let i=0, attribPath; attribPath = this.attribPaths[i]; i++) {
2701
3475
  if (attribPath instanceof PathToKey) // The list key is never a component arg.
2702
3476
  continue;
3477
+ // Event attributes like onchange=${...} are bound with addEventListener when the
3478
+ // PathToEvent itself is applied. They must not also become constructor fields:
3479
+ // a component that assigns its fields onto itself would set the native on*
3480
+ // property, making the handler fire a second time with only the (event) argument
3481
+ // instead of Solarite's documented (event, element) signature.
3482
+ if (attribPath instanceof PathToEvent)
3483
+ continue;
2703
3484
  if (attribPath instanceof PathToAttribValue) {
2704
- let name = Util.dashesToCamel(attribPath.attrName);
3485
+ let name = Util.dashesToCamel(attribPath.attribName);
2705
3486
 
2706
3487
  // Resolve two way bindimg path before we pass it to the component.
2707
3488
  let value = attribPath.getValue(exprs[i]);
@@ -2731,8 +3512,27 @@ class PathToComponent extends Path {
2731
3512
  // 2a. Instantiate component
2732
3513
  let tagName = (isAttrib || el.tagName.slice(0, -21)).toLowerCase(); // Remove -SOLARITE-PLACEHOLDER
2733
3514
  let Constructor = customElements.get(tagName);
2734
- if (!Constructor)
2735
- throw new Error(`Must call customElements.define('${tagName}', Class) before using it.`);
3515
+
3516
+ // Not defined yet (e.g. the module is being lazily imported): keep the placeholder
3517
+ // and instantiate when the definition lands, like a native custom-element upgrade.
3518
+ // deferredExprs always holds the LATEST exprs so re-renders while undefined win.
3519
+ if (!Constructor) {
3520
+ this.deferredExprs = exprs;
3521
+ if (!this.whenDefinedPending) {
3522
+ this.whenDefinedPending = true;
3523
+ console.warn(`Solarite: <${tagName}> is not defined yet; waiting for customElements.define().`);
3524
+ customElements.whenDefined(tagName).then(() => {
3525
+ this.whenDefinedPending = false;
3526
+ let deferred = this.deferredExprs;
3527
+ this.deferredExprs = null;
3528
+ // Skip if a newer render already instantiated or replaced the placeholder.
3529
+ if (deferred && this.nodeMarker === el && el.tagName.endsWith('-SOLARITE-PLACEHOLDER'))
3530
+ this.applyAll(deferred);
3531
+ });
3532
+ }
3533
+ Globals$1.currentSlotChildren = null;
3534
+ return;
3535
+ }
2736
3536
 
2737
3537
  Globals$1.currentSlotChildren = [...el.childNodes]; // TODO: Does this need to be a stack?
2738
3538
  let newEl = new Constructor(attribs);
@@ -2750,8 +3550,10 @@ class PathToComponent extends Path {
2750
3550
  for (let name in attribs) {
2751
3551
  let val = attribs[name];
2752
3552
  let valType = typeof val;
3553
+ // Only true and false can reach here, so the undefined/null halves of the
3554
+ // falsy test this used to spell out could never have decided anything.
2753
3555
  if (valType === 'boolean') {
2754
- if (val !== false && val !== undefined && val !== null) // Util.isFalsy() inlined
3556
+ if (val)
2755
3557
  newEl.setAttribute(name, '');
2756
3558
  }
2757
3559
 
@@ -2764,7 +3566,7 @@ class PathToComponent extends Path {
2764
3566
  // 2c. If an id pointed at the placeholder, update it to point to the new element.
2765
3567
  let id = newEl.getAttribute('data-id') || newEl.getAttribute('id');
2766
3568
  if (id)
2767
- delve(this.parentNg.getRootNode(), id.split(/\./g), newEl);
3569
+ delve(this.parentNg.getRootEl(), id.split(/\./g), newEl);
2768
3570
 
2769
3571
  // 2d. Update paths to use replaced element.
2770
3572
  let ng = this.parentNg;
@@ -2790,7 +3592,7 @@ class PathToComponent extends Path {
2790
3592
  for (let i=0, attribPath; attribPath = this.attribPaths[i]; i++) {
2791
3593
  attribPath.parentNg = this.parentNg;
2792
3594
  attribPath.nodeMarker = newEl;
2793
- attribPath.apply(exprs[i]);
3595
+ attribPath.applyAll(exprs[i]);
2794
3596
  }
2795
3597
 
2796
3598
  // 2e. Swap it to the DOM.
@@ -2809,21 +3611,16 @@ class PathToComponent extends Path {
2809
3611
  * @param pathOffset {int}
2810
3612
  * @return {Path} */
2811
3613
  clone(newRoot, pathOffset=0) {
2812
- /*#IFDEV*/this.verify();/*#ENDIF*/
2813
- let nodeMarker = this.getNewNodeMarker(newRoot, pathOffset);
2814
- let result = new PathToComponent(null, nodeMarker);
3614
+ // A component path's nodeBefore is always null (the constructor discards it), so the
3615
+ // base clone() resolves only the nodeMarker and hands back a new PathToComponent.
3616
+ let result = super.clone(newRoot, pathOffset);
2815
3617
  result.attribPaths = this.attribPaths.map(path => path.clone(newRoot, pathOffset));
2816
-
2817
- //#IFDEV
2818
- result.verify();
2819
- //#ENDIF
2820
-
2821
3618
  return result;
2822
3619
  }
2823
3620
 
2824
3621
  getExpressionCount() { return 0 }
2825
3622
 
2826
- //#IFDEV
3623
+ //#IFDEBUG
2827
3624
  verify() {
2828
3625
  super.verify();
2829
3626
  assert(this.nodeMarker.nodeType === Node.ELEMENT_NODE);
@@ -2847,7 +3644,7 @@ class Shell {
2847
3644
 
2848
3645
  /**
2849
3646
  * @type {DocumentFragment|Text} DOM parent of the shell's nodes. */
2850
- fragment;
3647
+ docFrag;
2851
3648
 
2852
3649
  /** @type {Path[]} Paths to where expressions should go. */
2853
3650
  paths = [];
@@ -2867,10 +3664,22 @@ class Shell {
2867
3664
  /** @type {boolean} True if any of this Shell's own paths is a PathToComponent. */
2868
3665
  hasComponentPaths = false;
2869
3666
 
3667
+ /** @type {boolean} True if any path binds an attribute that's a live HTML property
3668
+ * (checked, value, selected — Util.isHtmlProp). Users flip those underneath the template,
3669
+ * so "expression unchanged" doesn't mean "DOM unchanged" and the skip shortcuts exempt them. */
3670
+ hasLivePropPaths = false;
3671
+
2870
3672
  /** @type {boolean} True if every path consumes exactly one expression and none are components.
2871
3673
  * Lets NodeGroup.applyExprs() use a fast loop without allocating per-path expression arrays. */
2872
3674
  pathsSingleExpr = false;
2873
3675
 
3676
+ /** @type {boolean} True when a NodeGroup whose values are unchanged still has work to do:
3677
+ * components re-render so changes deeper in the tree surface, and live HTML properties are
3678
+ * rewritten because a click can flip them underneath the cached expression. The list scans
3679
+ * check this before calling PathToNodes.refreshSameItem(), so the overwhelmingly common
3680
+ * unchanged row costs one field read instead of a call. */
3681
+ needsRefresh = false;
3682
+
2874
3683
  /** @type {boolean} True if this Shell has any ids, styles, or scripts. */
2875
3684
  hasEmbeds = false;
2876
3685
 
@@ -2886,6 +3695,52 @@ class Shell {
2886
3695
  * with no per-instance Path objects. See the stampPaths setup in the constructor. */
2887
3696
  stampable = false;
2888
3697
 
3698
+ // The remaining fields are only filled in for some shells (resolve program, stampable),
3699
+ // but they're all declared here so every Shell instance shares one hidden class.
3700
+ // NodeGroup's per-row code (its constructor, applyStamp, resolveStampSlots) reads these
3701
+ // off whichever shell it's given, and a single shape keeps those loads monomorphic.
3702
+
3703
+ /** @type {?string} The Template close key, cached here by the NodeGroup constructor so
3704
+ * each new template row skips a WeakMap lookup. See Template.getCloseKey(). */
3705
+ closeKey;
3706
+
3707
+ /** @type {?int[]} The resolve program: flat [parentSlot, childIndex] pairs in dependency
3708
+ * order; pair i fills slot i+1, slot 0 being the fragment. Built by buildResolveProgram();
3709
+ * undefined for shells with components. */
3710
+ resolveOps;
3711
+
3712
+ /** @type {?Node[]} Reusable scratch array for resolved nodes; safe because resolution
3713
+ * never re-enters. */
3714
+ resolveSlots;
3715
+
3716
+ // The stamp program, set only when stampable is true:
3717
+
3718
+ /** @type {?int[]} Indexes of PathToNodes paths, checked for primitive exprs before stamping. */
3719
+ nodesPathIdx;
3720
+
3721
+ /** @type {?Path[]} One shared stamper per path; nodeMarker/parentNg are set per use. */
3722
+ stampPaths;
3723
+
3724
+ /** @type {?Uint8Array} Opcode per path; see the stamp-program comment in the constructor. */
3725
+ stampOp;
3726
+
3727
+ /** @type {?Uint16Array} paths[i].markerSlot, in a flat array so the hot loop
3728
+ * doesn't load the Path object to find its slot. */
3729
+ stampSlot;
3730
+
3731
+ /** @type {?Path[]} Per-path extra the stamp program needs: the event stamper for op 3
3732
+ * (it carries delegatedKey and eventName), the attribute name for op 4, null otherwise. */
3733
+ stampAux;
3734
+
3735
+ /** @type {?string[]} The delegatable event names this shell binds, so a loop can register
3736
+ * their dispatchers once for the whole run of rows instead of testing every bound node. */
3737
+ stampEventNames;
3738
+
3739
+ /** @type {?Uint8Array} Per-path flags the in-place rewrite loop needs, so it reads one byte
3740
+ * from a flat array instead of two properties from a Path object it otherwise wouldn't
3741
+ * touch. Bit 1 = the path binds a live HTML property, bit 2 = it's a whole-parent child. */
3742
+ stampFlags;
3743
+
2889
3744
  /**
2890
3745
  * Create the nodes but without filling in the expressions.
2891
3746
  * This is useful because the expression-less nodes created by a template can be cached.
@@ -2895,13 +3750,13 @@ class Shell {
2895
3750
  if (!html)
2896
3751
  return;
2897
3752
 
2898
- //#IFDEV
3753
+ //#IFDEBUG
2899
3754
  this._html = html.join('');
2900
3755
  //#ENDIF
2901
3756
 
2902
3757
  // If no html tags or entities, just create a text node.
2903
3758
  if (html.length === 1 && !html[0].match(/[<&]/)) {
2904
- this.fragment = Globals$1.doc.createTextNode(html[0]);
3759
+ this.docFrag = Globals$1.doc.createTextNode(html[0]);
2905
3760
  return;
2906
3761
  }
2907
3762
 
@@ -2919,29 +3774,29 @@ class Shell {
2919
3774
  let frag = Globals$1.doc.createDocumentFragment();
2920
3775
  while (svgEl.firstChild)
2921
3776
  frag.append(svgEl.firstChild);
2922
- this.fragment = frag;
3777
+ this.docFrag = frag;
2923
3778
  }
2924
3779
  else {
2925
3780
  template.innerHTML = htmlWithPlaceholders;
2926
- this.fragment = template.content;
3781
+ this.docFrag = template.content;
2927
3782
  }
2928
3783
  }
2929
3784
  else { // Create one text node, so shell isn't empty and NodeGroups created from it have something to point the startNode and endNode at.
2930
3785
  template.content.append(Globals$1.doc.createTextNode(''));
2931
- this.fragment = template.content;
3786
+ this.docFrag = template.content;
2932
3787
  }
2933
3788
 
2934
3789
  // 1b. Remove whitespace-only text nodes inside table-structure elements.
2935
3790
  // The parser foster-parents non-whitespace text out of tables, and whitespace-only
2936
3791
  // text between cells/rows is never rendered, so removing it is invisible.
2937
3792
  // Smaller fragments make cloning, path resolution, and insertion faster.
2938
- stripTableWhitespace(this.fragment);
3793
+ stripTableWhitespace(this.docFrag);
2939
3794
 
2940
3795
  // 2. Find placeholders
2941
3796
  let node;
2942
3797
  let toRemove = [];
2943
3798
  let placeholdersUsed = 0;
2944
- const walker = Globals$1.doc.createTreeWalker(this.fragment, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT);
3799
+ const walker = Globals$1.doc.createTreeWalker(this.docFrag, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT);
2945
3800
  while (node = walker.nextNode()) {
2946
3801
 
2947
3802
  // Remove previous elements after each iteration, so paths will still be calculated correctly.
@@ -2959,13 +3814,20 @@ class Shell {
2959
3814
  // The reserved key attribute identifies this template within a keyed list.
2960
3815
  // It's consumed here and never written to the DOM or passed to components.
2961
3816
  if (attr.name === 'key') {
3817
+
3818
+ // These three are template-authoring mistakes, and every one of them fails SILENTLY if
3819
+ // it isn't caught: the reconciler would key rows on a garbage value and reuse the wrong
3820
+ // DOM, with nothing reported. So they ship, unlike the assertions elsewhere in this
3821
+ // file. The cost is one regex split per unique template \u2014 never per render, never per
3822
+ // row \u2014 which is why they are affordable to keep.
2962
3823
  let parts = attr.value.split(/[\ue000-\uf8ff]/g);
2963
3824
  if (parts.length !== 2 || parts[0] !== '' || parts[1] !== '')
2964
- throw new Error(`The key attribute is reserved and must be a single expression: key=\${...}`);
2965
- if (node.parentNode !== this.fragment)
2966
- throw new Error(`The key attribute must be on a top-level element of its template.`);
3825
+ throw new Error(`Solarite: key must be one whole expression.`);
3826
+ if (node.parentNode !== this.docFrag)
3827
+ throw new Error(`Solarite: key must be on a top-level element.`);
2967
3828
  if (this.keyIndex >= 0)
2968
- throw new Error(`A template can have only one key attribute.`);
3829
+ throw new Error(`Solarite: duplicate key attribute.`);
3830
+
2969
3831
  this.keyIndex = attr.value.charCodeAt(0) - attribPlaceholder;
2970
3832
 
2971
3833
  let path = new PathToKey(null, node);
@@ -3010,19 +3872,33 @@ class Shell {
3010
3872
  }
3011
3873
 
3012
3874
  placeholdersUsed += parts.length - 1;
3013
- // In svgMode, setting typed SVG attributes (viewBox, r, etc.) with the placeholders
3014
- // stripped out makes the browser log parse errors, both here and when the fragment is cloned.
3015
- // Remove the attribute instead; apply() recreates it with the real values.
3016
- // Event attributes bound to a single expression are removed because they bind via
3017
- // addEventListener; leaving an empty onclick="" attribute violates a strict CSP when the event fires.
3018
- if (svgMode || (isEvent && !nonEmptyParts))
3875
+ // An attribute whose whole value is one expression is removed from the shell:
3876
+ // its stamped value is always the empty string, so every clone would carry a
3877
+ // useless empty attribute that costs storage on creation and a slot in the
3878
+ // element's attribute list forever, and apply() writes the real value anyway
3879
+ // (a missing attribute reads back as '', so an empty expression still writes
3880
+ // nothing). Event attributes must be removed for the same reason plus a
3881
+ // stricter one: an empty onclick="" violates a strict CSP when the event fires.
3882
+ // In svgMode, setting typed SVG attributes (viewBox, r, etc.) with the
3883
+ // placeholders stripped out makes the browser log parse errors, both here and
3884
+ // when the fragment is cloned, so those are removed whether or not they're whole.
3885
+ if (svgMode || !nonEmptyParts)
3019
3886
  node.removeAttribute(attr.name);
3020
- else try {
3887
+
3888
+ // setAttribute throws only when the template author wrote a name the browser
3889
+ // refuses, such as one holding a space or a quote. That name comes from a tagged
3890
+ // template literal's static text, so it is a typo that surfaces the first time the
3891
+ // template renders and can never appear later or for only some users. Development
3892
+ // therefore wraps the call to rethrow with the attribute name and the tag included,
3893
+ // because the browser's own DOMException names neither and leaves the author
3894
+ // hunting. Production ships the bare call and lets that DOMException through: the
3895
+ // friendlier wording is only worth its bytes to whoever can still fix the template.
3896
+ else /*#IFDEBUG*/try {/*#ENDIF*/
3021
3897
  node.setAttribute(attr.name, parts.join(''));
3022
- }
3898
+ /*#IFDEBUG*/}
3023
3899
  catch (e) {
3024
3900
  throw new Error(`Error setting attribute "${attr.name}" on node <${node.tagName}>: ${e.message}`);
3025
- }
3901
+ }/*#ENDIF*/
3026
3902
  }
3027
3903
  }
3028
3904
  }
@@ -3044,7 +3920,7 @@ class Shell {
3044
3920
  else if (node.nodeType === 8 && node.nodeValue === '!✨!') {
3045
3921
 
3046
3922
  if (node?.parentNode?.closest && node?.parentNode?.closest('[contenteditable]'))
3047
- throw new Error(`Contenteditable can't have expressions inside them. Use <div contenteditable value="\${...}"> instead.`);
3923
+ throw new Error(`Solarite: no \${...} inside contenteditable; use value="\${...}".`);
3048
3924
 
3049
3925
  let parent = node.parentNode;
3050
3926
 
@@ -3068,7 +3944,7 @@ class Shell {
3068
3944
  nodeBefore = Globals$1.doc.createComment('Path:'+this.paths.length);
3069
3945
  node.parentNode.insertBefore(nodeBefore, node);
3070
3946
  }
3071
- /*#IFDEV*/assert(nodeBefore);/*#ENDIF*/
3947
+ /*#IFDEBUG*/assert(nodeBefore);/*#ENDIF*/
3072
3948
 
3073
3949
  // Get the next node.
3074
3950
  let nodeMarker;
@@ -3083,7 +3959,7 @@ class Shell {
3083
3959
  nodeMarker = node;
3084
3960
  nodeMarker.textContent = 'PathEnd:'+ this.paths.length;
3085
3961
  }
3086
- /*#IFDEV*/assert(nodeMarker);/*#ENDIF*/
3962
+ /*#IFDEBUG*/assert(nodeMarker);/*#ENDIF*/
3087
3963
 
3088
3964
  let path = new PathToNodes(nodeBefore, nodeMarker);
3089
3965
  this.paths.push(path);
@@ -3091,11 +3967,6 @@ class Shell {
3091
3967
  }
3092
3968
  }
3093
3969
 
3094
- // Comments become text nodes when inside textareas.
3095
- else if (node.nodeType === 3 && node.parentNode?.tagName === 'TEXTAREA' && node.textContent.includes('<!--!✨!-->'))
3096
- throw new Error(`Textarea can't have expressions inside them. Use <textarea value="\${...}"> instead.`);
3097
-
3098
-
3099
3970
  // Sometimes users will comment out a block of html code that has expressions.
3100
3971
  // Here we look for expressions in comments.
3101
3972
  // We don't actually update them dynamically, but we still add paths for them.
@@ -3109,29 +3980,39 @@ class Shell {
3109
3980
  }
3110
3981
  }
3111
3982
 
3112
- // Replace comment placeholders inside script and style tags, which have become text nodes.
3113
- else if (node.nodeType === 3 && ['SCRIPT', 'STYLE'].includes(node.parentNode?.nodeName)) { // Node.TEXT_NODE
3114
- let parts = node.textContent.split(commentPlaceholder);
3115
- if (parts.length > 1) {
3116
-
3117
- let placeholders = [];
3118
- for (let i = 0; i<parts.length; i++) {
3119
- let current = Globals$1.doc.createTextNode(parts[i]);
3120
- node.parentNode.insertBefore(current, node);
3121
- if (i > 0)
3122
- placeholders.push(current);
3123
- }
3124
-
3125
- for (let i=0, node; node=placeholders[i]; i++) {
3126
- let path = new PathToNodes(node.previousSibling, node);
3127
- this.paths.push(path);
3128
- placeholdersUsed ++;
3983
+ // A few elements have raw-text bodies, which the html parser reads as literal characters
3984
+ // rather than as markup. A comment placeholder written inside one therefore never becomes
3985
+ // a comment node; it arrives here as ordinary text. A textarea can't support expressions
3986
+ // in its body at all, while script and style can, by splitting their text around each
3987
+ // placeholder so that every expression gets a text node of its own to write into.
3988
+ else if (node.nodeType === 3) { // Node.TEXT_NODE
3989
+ let parentName = node.parentNode?.nodeName;
3990
+
3991
+ if (parentName === 'TEXTAREA' && node.textContent.includes(commentPlaceholder))
3992
+ throw new Error(`Solarite: no \${...} inside textarea; use value="\${...}".`);
3993
+
3994
+ else if (parentName === 'SCRIPT' || parentName === 'STYLE') {
3995
+ let parts = node.textContent.split(commentPlaceholder);
3996
+ if (parts.length > 1) {
3997
+
3998
+ // Every part is inserted before the original node, in order, so from the second
3999
+ // part onward the text node made on the previous iteration is already sitting
4000
+ // immediately before this one and serves as the new path's nodeBefore.
4001
+ for (let i = 0; i<parts.length; i++) {
4002
+ let current = Globals$1.doc.createTextNode(parts[i]);
4003
+ node.parentNode.insertBefore(current, node);
4004
+ if (i > 0) {
4005
+ let path = new PathToNodes(current.previousSibling, current);
4006
+ this.paths.push(path);
4007
+ placeholdersUsed ++;
4008
+
4009
+ /*#IFDEBUG*/path.verify();/*#ENDIF*/
4010
+ }
4011
+ }
3129
4012
 
3130
- /*#IFDEV*/path.verify();/*#ENDIF*/
4013
+ // Removing it here will mess up the treeWalker.
4014
+ toRemove.push(node);
3131
4015
  }
3132
-
3133
- // Removing them here will mess up the treeWalker.
3134
- toRemove.push(node);
3135
4016
  }
3136
4017
  }
3137
4018
  }
@@ -3140,31 +4021,37 @@ class Shell {
3140
4021
  // Less than or equal because there can be one path to multiple expressions
3141
4022
  // if those expressions are in the same attribute value.
3142
4023
  if (placeholdersUsed !== html.length-1)
3143
- throw new Error(`Could not parse expressions in template. Check for duplicate attributes or malformed html: ${html.join('${...}')}`);
4024
+ throw new Error(`Solarite: bad html or duplicate attribute: ${html.join('${...}')}`);
3144
4025
 
3145
4026
  for (let path of this.paths) {
3146
- if (path.nodeBefore)
3147
- path.nodeBeforeIndex = Array.prototype.indexOf.call(path.nodeBefore.parentNode.childNodes, path.nodeBefore);
4027
+ // -1 when the path has no nodeBefore. Assigned unconditionally so every shell path
4028
+ // of a given class takes the same property-addition order and shares one hidden class.
4029
+ path.nodeBeforeIndex = path.nodeBefore
4030
+ ? Array.prototype.indexOf.call(path.nodeBefore.parentNode.childNodes, path.nodeBefore)
4031
+ : -1;
3148
4032
 
3149
4033
  // Must be calculated after we remove the toRemove nodes:
3150
4034
  path.nodeMarkerPath = Path.get(path.nodeMarker);
3151
-
3152
-
3153
4035
  }
3154
4036
 
3155
4037
  this.findEmbeds();
3156
- this.buildResolveProgram();
3157
4038
 
4039
+ // This scan must run before buildResolveProgram(), which skips shells with components
4040
+ // and reads hasComponentPaths rather than walking the paths a second time.
3158
4041
  this.pathsSingleExpr = true;
3159
4042
  for (let path of this.paths) {
3160
4043
  if (path instanceof PathToComponent) {
3161
4044
  this.hasComponentPaths = true;
3162
4045
  this.pathsSingleExpr = false;
3163
- break; // Both facts are now decided.
3164
4046
  }
3165
- if (path.getExpressionCount() !== 1)
3166
- this.pathsSingleExpr = false; // Keep scanning for components.
4047
+ else if (path.getExpressionCount() !== 1)
4048
+ this.pathsSingleExpr = false;
4049
+ if (path.isHtmlProperty) // needs the full scan — no early break
4050
+ this.hasLivePropPaths = true;
3167
4051
  }
4052
+ this.needsRefresh = this.hasComponentPaths || (this.hasLivePropPaths && this.pathsSingleExpr);
4053
+
4054
+ this.buildResolveProgram();
3168
4055
 
3169
4056
  // Stampable shells create NodeGroups without allocating any Path objects:
3170
4057
  // NodeGroup.applyStamp() writes expressions through these shared stamper paths,
@@ -3189,17 +4076,50 @@ class Shell {
3189
4076
  }
3190
4077
  if (ok) {
3191
4078
  this.stampable = true;
3192
-
3193
- /** @type {int[]} Indexes of PathToNodes paths, checked for primitive exprs before stamping. */
3194
4079
  this.nodesPathIdx = nodesIdx;
3195
-
3196
- /** @type {Path[]} One shared stamper per path; nodeMarker/parentNg are set per use. */
3197
4080
  this.stampPaths = this.paths.map(p => p.cloneWithNodes(null, p.nodeMarker));
3198
4081
 
4082
+ // Compiled stamp program: one opcode per path lets applyStamp() write a fresh
4083
+ // row through a flat branch chain instead of dispatching applySingle() per path.
4084
+ // 0 = generic (shared stamper fallback), 1 = list key (no DOM), 2 = wholeParent
4085
+ // child text, 3 = delegatable single-expression event (written as node expandos
4086
+ // when the root delegates, the default).
4087
+ let n = this.paths.length;
4088
+ this.stampOp = new Uint8Array(n);
4089
+ this.stampSlot = new Uint16Array(n);
4090
+ this.stampAux = new Array(n).fill(null);
4091
+ this.stampFlags = new Uint8Array(n);
4092
+
4093
+ let eventNames = null;
4094
+ for (let i=0; i<n; i++) {
4095
+ let p = this.paths[i], sp = this.stampPaths[i];
4096
+ this.stampSlot[i] = p.markerSlot;
4097
+ this.stampFlags[i] = (sp.isHtmlProperty ? 1 : 0) | (sp.wholeParent ? 2 : 0);
4098
+ if (p instanceof PathToKey)
4099
+ this.stampOp[i] = 1;
4100
+ else if (sp.wholeParent)
4101
+ this.stampOp[i] = 2;
4102
+ else if (sp instanceof PathToEvent && sp.delegatedKey !== undefined && !sp.attrValue) {
4103
+ this.stampOp[i] = 3;
4104
+ this.stampAux[i] = sp;
4105
+ (eventNames ??= []).push(sp.eventName);
4106
+ }
4107
+
4108
+ // A plain attribute holding one whole expression. The shell no longer carries
4109
+ // the attribute at all (see the placeholder handling above), so on a freshly
4110
+ // cloned row the value is known to be absent and a string can be written
4111
+ // without first reading back what's there.
4112
+ else if (sp instanceof PathToAttribValue && !sp.attrValue && !sp.isHtmlProperty
4113
+ && !sp.isComponentAttrib) {
4114
+ this.stampOp[i] = 4;
4115
+ this.stampAux[i] = sp.attribName;
4116
+ }
4117
+ }
4118
+ this.stampEventNames = eventNames;
3199
4119
  }
3200
4120
  }
3201
4121
 
3202
- /*#IFDEV*/this.verify();/*#ENDIF*/
4122
+ /*#IFDEBUG*/this.verify();/*#ENDIF*/
3203
4123
  }
3204
4124
 
3205
4125
  /**
@@ -3210,42 +4130,64 @@ class Shell {
3210
4130
  * @param htmlChunks {string[]}
3211
4131
  * @returns {string} Html with the placeholders in place. */
3212
4132
  static addPlaceholders(htmlChunks) {
3213
- let result = [];
4133
+ let result = '';
4134
+
4135
+ // Where the tokenizer is as it walks the chunks. An expression can sit in the middle of an attribute
4136
+ // value, so both of these have to survive from one chunk to the next. Nothing else has to: an
4137
+ // expression anywhere inside a tag gets the same attribute placeholder, so the machine only has to
4138
+ // know whether it is inside a tag at all, and whether a quoted value is currently open.
4139
+ let inTag = false; // True from the '<' that opens a tag or comment through the '>' that closes it.
4140
+ let quote = null; // The quote character that opened the attribute value we're inside of: null, '"', or "'".
3214
4141
 
3215
- let htmlParser = new HtmlParser(); // Reset the context.
3216
4142
  for (let i = 0; i < htmlChunks.length; i++) {
3217
- let lastHtml = htmlChunks[i];
4143
+ let html = htmlChunks[i];
3218
4144
 
3219
4145
  // Append -solarite-placholder to web component tags, so we can pass args to them when they're instantiated.
3220
- let lastIndex = 0;
3221
- let context = htmlParser.parse(lastHtml, (html, index, prevContext/*, nextContext*/) => { // This function is called every time the html context changes.
3222
- if (lastIndex !== index) {
3223
- let token = html.slice(lastIndex, index);
3224
-
3225
- if (prevContext === HtmlParser.Tag) {
3226
- // Find Web Component tags and append -solarite-placeholder to their tag names
3227
- // This way we can gather their constructor arguments and their children before we call their constructor.
3228
- // Later, PathToComponent.apply() will replace them with the real components.
3229
- // Ctrl+F "solarite-placeholder" in project to find all code that manages subcomponents.
3230
- const isWebComponentTagName = /^<\/?[a-z][a-z0-9]*-[a-z0-9-]+/i; // a dash in the middle
3231
- token = token.replace(isWebComponentTagName, match => match + '-SOLARITE-PLACEHOLDER'); // caps to match other instances of this string, for better compression.
4146
+ let lastIndex = 0; // Start of the run of this chunk not yet copied into result.
4147
+ for (let j = 0; j < html.length; j++) {
4148
+ const char = html[j];
4149
+
4150
+ if (!inTag) {
4151
+ if (char === '<' && html[j + 1].match(/[/a-z!]/i)) { // Start of a tag or comment.
4152
+ inTag = true;
4153
+
4154
+ // A component suffix can only ever be added right here, at the '<' that opens the tag, so
4155
+ // the name is matched on the spot with a sticky regex rather than collected into a buffer
4156
+ // and matched later. The greedy tag-name class can't run past the name, because every
4157
+ // character that can follow a tag name is outside it.
4158
+ isWebComponentTagName.lastIndex = j;
4159
+ let match = isWebComponentTagName.exec(html);
4160
+ if (match) {
4161
+ let end = j + match[0].length;
4162
+ result += html.slice(lastIndex, end) + '-SOLARITE-PLACEHOLDER';
4163
+ lastIndex = end;
4164
+ }
3232
4165
  }
4166
+ }
3233
4167
 
3234
- result.push(token);
4168
+ // Inside a tag, only two characters end anything: the quote that closes the value we're in, or,
4169
+ // when we're not in one, the '>' that closes the tag. Attribute names, '=', unquoted values and
4170
+ // whitespace all need no handling at all.
4171
+ else if (quote) {
4172
+ if (char === quote)
4173
+ quote = null;
3235
4174
  }
3236
- lastIndex = index;
3237
- });
4175
+ else if (char === '"' || char === "'")
4176
+ quote = char;
4177
+ else if (char === '>')
4178
+ inTag = false;
4179
+ }
4180
+
4181
+ result += html.slice(lastIndex);
3238
4182
 
3239
4183
  // Insert placeholders
3240
- if (i < htmlChunks.length - 1) {
3241
- if (context === HtmlParser.Text)
3242
- result.push(commentPlaceholder); // Comment Placeholder. because we can't put text in between <tr> tags for example.
3243
- else
3244
- result.push(String.fromCharCode(attribPlaceholder + i));
3245
- }
4184
+ if (i < htmlChunks.length - 1)
4185
+ result += inTag
4186
+ ? String.fromCharCode(attribPlaceholder + i)
4187
+ : commentPlaceholder; // Comment Placeholder. because we can't put text in between <tr> tags for example.
3246
4188
  }
3247
4189
 
3248
- return result.join('');
4190
+ return result;
3249
4191
  }
3250
4192
 
3251
4193
  /**
@@ -3257,21 +4199,18 @@ class Shell {
3257
4199
  * this.ids
3258
4200
  * this.staticComponents */
3259
4201
  findEmbeds() {
3260
- this.scripts = Array.prototype.map.call(this.fragment.querySelectorAll('script'), el => Path.get(el));
4202
+ this.scripts = Array.prototype.map.call(this.docFrag.querySelectorAll('script'), el => Path.get(el));
3261
4203
 
3262
4204
  // TODO: only find styles that have Paths in them?
3263
- this.styles = Array.prototype.map.call(this.fragment.querySelectorAll('style'), el => Path.get(el));
4205
+ this.styles = Array.prototype.map.call(this.docFrag.querySelectorAll('style'), el => Path.get(el));
3264
4206
 
3265
- let idEls = this.fragment.querySelectorAll('[id],[data-id]');
3266
-
3267
- // Check for valid id names.
3268
- for (let el of idEls) {
3269
- let id = el.getAttribute('data-id') || el.getAttribute('id');
3270
- if (Globals$1.div.hasOwnProperty(id))
3271
- throw new Error(`<${el.tagName.toLowerCase()} id="${id}"> can't override existing HTMLElement id property.`)
3272
- }
3273
-
3274
- this.ids = Array.prototype.map.call(idEls, el => Path.get(el));
4207
+ // An id that would clobber a built-in element property is reported by Util.bindId(), which
4208
+ // asks the real component object, with `in`, at the moment the binding happens. The check
4209
+ // that used to stand here asked Globals.div.hasOwnProperty(id) instead, and a freshly
4210
+ // created element has no own properties at all — every DOM property an element exposes
4211
+ // lives on its interface prototype — so that test could never be true and the error it
4212
+ // guarded was never reachable.
4213
+ this.ids = Array.prototype.map.call(this.docFrag.querySelectorAll('[id],[data-id]'), el => Path.get(el));
3275
4214
 
3276
4215
  this.hasEmbeds = this.ids.length > 0 || this.styles.length > 0 || this.scripts.length > 0;
3277
4216
  }
@@ -3282,25 +4221,37 @@ class Shell {
3282
4221
  * Replaces per-path root-to-node walks in the hot NodeGroup creation path.
3283
4222
  * Skipped for shells with components, whose clone() has special attribPaths behavior. */
3284
4223
  buildResolveProgram() {
3285
- let hasComponents = false;
3286
- for (let path of this.paths)
3287
- if (path instanceof PathToComponent) {
3288
- hasComponents = true;
3289
- break;
3290
- }
3291
- if (hasComponents || !this.paths.length)
4224
+ if (this.hasComponentPaths || !this.paths.length)
3292
4225
  return;
3293
4226
 
3294
4227
  let ops = [];
3295
4228
  let slotOf = new Map();
3296
- let frag = this.fragment;
4229
+ let frag = this.docFrag;
3297
4230
  let nextSlot = 1;
3298
4231
  let getSlot = node => {
3299
4232
  if (node === frag)
3300
4233
  return 0;
3301
4234
  let s = slotOf.get(node);
3302
4235
  if (s === undefined) {
3303
- ops.push(getSlot(node.parentNode), Array.prototype.indexOf.call(node.parentNode.childNodes, node));
4236
+ // Two ways to reach a node, costing one pointer step each: walk forward from an
4237
+ // already-resolved earlier sibling, or take the parent's firstChild and walk
4238
+ // forward. Sibling steps win whenever they're no more numerous, and they can
4239
+ // also spare the parent a slot of its own — in a row of cells, resolving each
4240
+ // <td> from the previous one is one step instead of firstChild plus its index.
4241
+ let d = 0, from = -1;
4242
+ for (let sib = node.previousSibling; sib; sib = sib.previousSibling) {
4243
+ d++;
4244
+ let ss = slotOf.get(sib);
4245
+ if (ss !== undefined) {
4246
+ from = ss;
4247
+ break;
4248
+ }
4249
+ }
4250
+ let index = Array.prototype.indexOf.call(node.parentNode.childNodes, node);
4251
+ if (from >= 0 && d <= index + 1)
4252
+ ops.push(from, -d); // A negative step count means "walk nextSibling from that slot".
4253
+ else
4254
+ ops.push(getSlot(node.parentNode), index);
3304
4255
  s = nextSlot++;
3305
4256
  slotOf.set(node, s);
3306
4257
  }
@@ -3311,10 +4262,7 @@ class Shell {
3311
4262
  path.beforeSlot = path.nodeBefore ? getSlot(path.nodeBefore) : -1;
3312
4263
  }
3313
4264
 
3314
- /** @type {?int[]} Flat [parentSlot, childIndex] pairs; pair i fills slot i+1. */
3315
4265
  this.resolveOps = ops;
3316
-
3317
- /** @type {Node[]} Reusable scratch array for resolved nodes; safe because resolution never re-enters. */
3318
4266
  this.resolveSlots = new Array(nextSlot);
3319
4267
 
3320
4268
  // A lone root element means slot 1 is always that element (the first op pair is [0, 0]),
@@ -3349,15 +4297,15 @@ class Shell {
3349
4297
  lastSvgMode = svgMode;
3350
4298
  lastShell = result;
3351
4299
 
3352
- /*#IFDEV*/result.verify();/*#ENDIF*/
4300
+ /*#IFDEBUG*/result.verify();/*#ENDIF*/
3353
4301
  return result;
3354
4302
  }
3355
4303
 
3356
- //#IFDEV
4304
+ //#IFDEBUG
3357
4305
  // For debugging only:
3358
4306
  verify() {
3359
4307
  for (let path of this.paths) {
3360
- assert(this.fragment.contains(path.getParentNode()));
4308
+ assert(this.docFrag.contains(path.getParentNode()));
3361
4309
  path.verify();
3362
4310
  }
3363
4311
  }
@@ -3367,6 +4315,15 @@ class Shell {
3367
4315
 
3368
4316
  const commentPlaceholder = `<!--!✨!-->`;
3369
4317
 
4318
+ // A tag name with a dash in the middle, which is what makes an element a web component. addPlaceholders()
4319
+ // tests this at each '<' that opens a tag, and a match gets -solarite-placeholder appended to its tag name.
4320
+ // That way we can gather a component's constructor arguments and its children before we call its constructor;
4321
+ // later PathToComponent.applyAll() replaces the placeholder tag with the real component. The suffix is written in
4322
+ // caps wherever it appears, so that the several copies of it in this project compress well. It's sticky rather
4323
+ // than anchored so it can be tested at an offset within the chunk instead of against a sliced-out token.
4324
+ // Ctrl+F "solarite-placeholder" in project to find all code that manages subcomponents.
4325
+ const isWebComponentTagName = /<\/?[a-z][a-z0-9]*-[a-z0-9-]+/iy;
4326
+
3370
4327
  // Elements whose whitespace-only text children are never rendered.
3371
4328
  const tableTags = ['TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR'];
3372
4329
 
@@ -3395,6 +4352,50 @@ const attribPlaceholder = 0xe000; // https://en.wikipedia.org/wiki/Private_Use_A
3395
4352
 
3396
4353
  /** @typedef {boolean|string|number|function|Object|Array|Date|Node|Template} Expr */
3397
4354
 
4355
+ /** Stand-in Shell for text NodeGroups, which are never parsed from html. Its default field
4356
+ * values (no components, no live properties, no single-expression paths) are exactly what the
4357
+ * per-row code must see for a bare Text node, so ng.shell is never null. */
4358
+ const textShell = new Shell();
4359
+
4360
+ // The Shell whose delegated dispatchers a root last registered, kept on the RootNodeGroup so
4361
+ // that a run of rows checks one field instead of asking at every bound node. A Symbol rather
4362
+ // than a declared field, since only root NodeGroups ever carry it and a declared field would
4363
+ // cost a slot on every row. The delegation mode isn't part of it: it comes from the root's
4364
+ // render options, which are fixed when the root is created.
4365
+ const lastStampedShellKey = Symbol('solariteStampedShell');
4366
+
4367
+ /**
4368
+ * Run a Shell's precomputed resolve program (see Shell.buildResolveProgram) into the shell's
4369
+ * shared slots array, which the caller has already seeded with its starting node.
4370
+ * Each node is reached with firstChild/nextSibling pointer walks instead of childNodes[index];
4371
+ * the live NodeList indexing is markedly slower, and the indices are small (markers are
4372
+ * elements, often the first child after whitespace stripping). A negative step count means the
4373
+ * program reaches this node by walking forward from an earlier sibling's slot instead of from
4374
+ * its parent.
4375
+ * @param slots {Node[]} The shell's shared scratch array; slot 0 is the fragment.
4376
+ * @param ops {int[]} Flat [parentSlot, childIndex] pairs in dependency order.
4377
+ * @param i {int} Index of the first op pair to run; earlier pairs are pre-seeded by the caller.
4378
+ * @param s {int} Slot that pair fills.
4379
+ * @return {Node[]} slots, so callers can resolve and use it in one expression. */
4380
+ function runResolveOps(slots, ops, i, s) {
4381
+ for (; i<ops.length; i+=2, s++) {
4382
+ let k = ops[i+1], node;
4383
+ if (k < 0) {
4384
+ node = slots[ops[i]];
4385
+ do
4386
+ node = node.nextSibling;
4387
+ while (++k < 0);
4388
+ }
4389
+ else {
4390
+ node = slots[ops[i]].firstChild;
4391
+ for (; k>0; k--)
4392
+ node = node.nextSibling;
4393
+ }
4394
+ slots[s] = node;
4395
+ }
4396
+ return slots;
4397
+ }
4398
+
3398
4399
  /**
3399
4400
  * A group of Nodes instantiated from a Shell, with Expr's filled in.
3400
4401
  *
@@ -3428,11 +4429,11 @@ class NodeGroup {
3428
4429
  * matched by PathToNodes.applyKeyed(). Undefined for unkeyed NodeGroups. */
3429
4430
  key;
3430
4431
 
3431
- /** @type {boolean} True if any of this NodeGroup's own paths is a PathToComponent. */
3432
- hasComponentPaths = false;
3433
-
3434
- /** @type {boolean} True if every path consumes exactly one expression and none are components. */
3435
- pathsSingleExpr = false;
4432
+ /** @type {Shell} The Shell this NodeGroup was cloned from, so the per-row code can read
4433
+ * hasComponentPaths/hasLivePropPaths/pathsSingleExpr and the stamp program off it instead
4434
+ * of copying them onto every instance and re-looking the Shell up on every apply.
4435
+ * Text NodeGroups get the shared empty textShell, which reports false for all of them. */
4436
+ shell;
3436
4437
 
3437
4438
  /** @type {boolean} True until applyExprs() finishes the first time.
3438
4439
  * While true, ancestor node caches can't reference this NodeGroup's nodes, so they don't need invalidation. */
@@ -3443,6 +4444,11 @@ class NodeGroup {
3443
4444
  * @type {Node[]} Cached result of getNodes() used only for improving performance.*/
3444
4445
  nodesCache;
3445
4446
 
4447
+ /** @type {?Node[]} Slot nodes resolved by the first rewriteStamp(); a stamped group's
4448
+ * element structure never changes while it stays stampable, so they're reused on every
4449
+ * later rewrite. Declared here so every NodeGroup keeps one monomorphic hidden class. */
4450
+ stampSlotsCache = null;
4451
+
3446
4452
  /**
3447
4453
  * A map between <style> Elements and their text content.
3448
4454
  * This lets NodeGroup.updateStyles() see when the style text has changed.
@@ -3464,7 +4470,7 @@ class NodeGroup {
3464
4470
  this.rootNg = parentPath?.parentNg?.rootNg || this;
3465
4471
  this.parentPath = parentPath;
3466
4472
 
3467
- /*#IFDEV*/assert(this.rootNg);/*#ENDIF*/
4473
+ /*#IFDEBUG*/assert(this.rootNg);/*#ENDIF*/
3468
4474
  this.template = template;
3469
4475
 
3470
4476
  // JSX templates carry their list key on the Template (tagged templates instead set it via
@@ -3475,24 +4481,22 @@ class NodeGroup {
3475
4481
  // If it's just a text node, skip a bunch of unnecessary steps.
3476
4482
  // el can be an existing Text node to adopt, from PathToNodes' bare-text fast path.
3477
4483
  if (template.isText) {
4484
+ this.shell = textShell;
3478
4485
  this.closeKey = template.getCloseKey();
3479
4486
  this.startNode = this.endNode = el || Globals$1.doc.createTextNode(template.html[0]);
3480
4487
  }
3481
4488
 
3482
4489
  else {
3483
4490
  // Get a cached version of the parsed and instantiated html, and Paths:
3484
- const shell = Shell.get(template.html, template.svgMode);
4491
+ const shell = this.shell = Shell.get(template.html, template.svgMode);
3485
4492
 
3486
4493
  // The shell caches the close key so each new template doesn't repeat the WeakMap lookup.
3487
4494
  this.closeKey = shell.closeKey ??= template.getCloseKey();
3488
4495
 
3489
- this.hasComponentPaths = shell.hasComponentPaths;
3490
- this.pathsSingleExpr = shell.pathsSingleExpr;
3491
-
3492
4496
  // A lone root element is cloned directly, skipping a throwaway fragment wrapper.
3493
4497
  // Only for child NodeGroups; RootNodeGroup's grafting expects a fragment.
3494
4498
  if (shell.singleRoot && parentPath !== null) {
3495
- const clone = shell.fragment.firstChild.cloneNode(true);
4499
+ const clone = shell.docFrag.firstChild.cloneNode(true);
3496
4500
  this.startNode = this.endNode = clone;
3497
4501
 
3498
4502
  // Stampable shells skip path creation entirely; the first applyExprs() routes
@@ -3501,7 +4505,7 @@ class NodeGroup {
3501
4505
  this.setPathsFromFragment(clone, shell, 0, true);
3502
4506
  }
3503
4507
  else {
3504
- const shellFragment = shell.fragment.cloneNode(true);
4508
+ const shellFragment = shell.docFrag.cloneNode(true);
3505
4509
 
3506
4510
  if (shellFragment.nodeType === 11) { // DocumentFragment
3507
4511
  this.startNode = shellFragment.firstChild;
@@ -3513,7 +4517,7 @@ class NodeGroup {
3513
4517
  }
3514
4518
  }
3515
4519
 
3516
- //#IFDEV
4520
+ //#IFDEBUG
3517
4521
  this.verify();
3518
4522
  //#ENDIF
3519
4523
  }
@@ -3544,10 +4548,14 @@ class NodeGroup {
3544
4548
  * Dispatches expression handling to other functions depending on the path type.
3545
4549
  * @param exprs {(*|*[]|function|Template)[]}
3546
4550
  * @param includeNonComponents {boolean} False to only apply component paths,
3547
- * used when the non-component exprs are known to be unchanged. */
3548
- applyExprs(exprs, includeNonComponents=true) {
3549
-
3550
- /*#IFDEV*/
4551
+ * used when the non-component exprs are known to be unchanged.
4552
+ * @param lastExprs {?Expr[]} The expressions applied last time, when the caller has them.
4553
+ * Paths that would provably do nothing with an unchanged expression are then skipped —
4554
+ * see Path.skipIfSame. A root template's event bindings are the usual beneficiaries:
4555
+ * they are the same handlers on every render, and re-binding them costs a call apiece. */
4556
+ applyExprs(exprs, includeNonComponents=true, lastExprs=null) {
4557
+
4558
+ /*#IFDEBUG*/
3551
4559
  this.verify();
3552
4560
  /*#ENDIF*/
3553
4561
 
@@ -3555,14 +4563,18 @@ class NodeGroup {
3555
4563
 
3556
4564
  // Fast path: every path consumes exactly one expression and none are components,
3557
4565
  // so skip the bookkeeping that maps expressions to paths.
3558
- if (this.pathsSingleExpr) {
4566
+ if (this.shell.pathsSingleExpr) {
3559
4567
  if (includeNonComponents) {
3560
4568
  if (paths === null) { // Created from a stampable shell; no paths yet.
3561
4569
  this.applyStamp(exprs);
3562
4570
  return;
3563
4571
  }
3564
- for (let i = paths.length - 1; i >= 0; i--)
3565
- paths[i].applySingle(exprs[i]);
4572
+ for (let i = paths.length - 1; i >= 0; i--) {
4573
+ let path = paths[i];
4574
+ if (lastExprs !== null && path.skipIfSame && lastExprs[i] === exprs[i])
4575
+ continue;
4576
+ path.applySingle(exprs[i]);
4577
+ }
3566
4578
 
3567
4579
  if (this.styles)
3568
4580
  this.updateStyles();
@@ -3589,7 +4601,7 @@ class NodeGroup {
3589
4601
  let exprIndex = exprs.length; // Update exprs at paths.
3590
4602
  let pathExprs = new Array(paths.length); // Store all the expressions that map to a single path. Only paths to attribute values can have more than one.
3591
4603
  for (let i = paths.length - 1, path; path = paths[i]; i--) {
3592
- if (i===0 && path instanceof PathToComponent && path.nodeMarker === this.getRootNode())
4604
+ if (i===0 && path instanceof PathToComponent && path.nodeMarker === this.getRootEl())
3593
4605
  continue;
3594
4606
 
3595
4607
  // Get the expressions associated with this path.
@@ -3601,15 +4613,15 @@ class NodeGroup {
3601
4613
  // They use expressions from the paths that provide their attributes.
3602
4614
  if (path instanceof PathToComponent) {
3603
4615
  let attribExprs = pathExprs.slice(i+1, i+1 + path.attribPaths.length); // +1 b/c we move forward from the component path.
3604
- path.apply(attribExprs);
4616
+ path.applyAll(attribExprs);
3605
4617
  }
3606
4618
  else if (includeNonComponents)
3607
- path.apply(pathExprs[i]);
4619
+ path.applyAll(pathExprs[i]);
3608
4620
  }
3609
4621
 
3610
4622
  // If there's leftover expressions, there's probably an issue with the Shell that created this NodeGroup,
3611
4623
  // and the number of paths not matching.
3612
- /*#IFDEV*/
4624
+ /*#IFDEBUG*/
3613
4625
  assert(exprIndex === 0);
3614
4626
  /*#ENDIF*/
3615
4627
 
@@ -3625,7 +4637,7 @@ class NodeGroup {
3625
4637
  }
3626
4638
  this.firstApply = false;
3627
4639
 
3628
- /*#IFDEV*/
4640
+ /*#IFDEBUG*/
3629
4641
  this.verify();
3630
4642
  /*#ENDIF*/
3631
4643
  }
@@ -3637,8 +4649,7 @@ class NodeGroup {
3637
4649
  * falls back to materializing real paths and applying normally.
3638
4650
  * @param exprs {Expr[]} */
3639
4651
  applyStamp(exprs) {
3640
- let template = this.template;
3641
- let shell = Shell.get(template.html, template.svgMode);
4652
+ let shell = this.shell;
3642
4653
 
3643
4654
  // 1. Bail to real paths when any child-node expression isn't a primitive.
3644
4655
  let nodesIdx = shell.nodesPathIdx;
@@ -3654,27 +4665,71 @@ class NodeGroup {
3654
4665
  }
3655
4666
  }
3656
4667
 
3657
- // 2. Resolve target nodes, then write each expression.
4668
+ // 2. Resolve target nodes, then run the shell's compiled stamp program: a flat
4669
+ // opcode per path replaces per-path applySingle() dispatch (see Shell.stampOp).
3658
4670
  let slots = this.resolveStampSlots(shell);
3659
- let paths = shell.paths, stampers = shell.stampPaths;
3660
- for (let i = paths.length - 1; i >= 0; i--) {
3661
- let stamper = stampers[i];
3662
- let marker = slots[paths[i].markerSlot];
3663
-
3664
- // A wholeParent text path's marker is the (freshly cloned, empty) only-child slot:
3665
- // write its text directly, skipping applySingle's branching and the shared-stamper
3666
- // bookkeeping. Child exprs are primitive here (step 1 bailed otherwise).
3667
- if (stamper.wholeParent) {
3668
- let v = exprs[i];
4671
+ let ops = shell.stampOp, slotIdx = shell.stampSlot, aux = shell.stampAux;
4672
+ let stampers = shell.stampPaths;
4673
+ let rootNg = this.rootNg;
4674
+ let root = rootNg.rootEl;
4675
+ let opt = rootNg.renderOptions?.eventDelegation;
4676
+ let delegateDoc = opt === 'document';
4677
+ let delegateAll = opt === undefined || opt === true || delegateDoc;
4678
+
4679
+ // Register this shell's delegated dispatchers once for a whole run of rows. They live on
4680
+ // the root, not on the bound nodes, so asking per node — as the general binding path has
4681
+ // to — would be a call and a set lookup for every handler in the list.
4682
+ let names = shell.stampEventNames;
4683
+ if (names !== null && delegateAll && rootNg[lastStampedShellKey] !== shell) {
4684
+ for (let k=0; k<names.length; k++)
4685
+ ensureDelegatedDispatcher(root, names[k], delegateDoc);
4686
+ rootNg[lastStampedShellKey] = shell;
4687
+ }
4688
+
4689
+ let firstApply = this.firstApply;
4690
+ for (let i = ops.length - 1; i >= 0; i--) {
4691
+ let v = exprs[i];
4692
+ let o = ops[i];
4693
+
4694
+ // Whole-parent child text: the marker is the (freshly cloned, empty) only-child
4695
+ // slot. Child exprs are primitive here (step 1 bailed otherwise).
4696
+ if (o === 2) {
3669
4697
  if (typeof v === 'number')
3670
4698
  v += '';
3671
- marker.textContent = v;
3672
- continue;
4699
+ slots[slotIdx[i]].textContent = v;
4700
+ }
4701
+
4702
+ // Delegatable event with a valid handler shape: write the node expandos
4703
+ // directly, mirroring bindEvent()'s delegated branch. An event-name-array
4704
+ // delegation option or an invalid value falls through to the generic stamper.
4705
+ else if (o === 3 && delegateAll
4706
+ && (typeof v === 'function' || (Array.isArray(v) && typeof v[0] === 'function'))) {
4707
+ let sp = aux[i];
4708
+ let node = slots[slotIdx[i]];
4709
+ node[sp.delegatedKey] = v;
4710
+ node[delegatedRootKey] = root;
4711
+ }
4712
+
4713
+ // A plain attribute on a freshly cloned row: the shell left it off, so an empty
4714
+ // value means there is simply nothing to write, and any other string can go
4715
+ // straight in without reading the attribute back first.
4716
+ else if (o === 4 && firstApply && typeof v === 'string') {
4717
+ if (v !== '')
4718
+ slots[slotIdx[i]].setAttribute(aux[i], v);
3673
4719
  }
3674
4720
 
3675
- stamper.nodeMarker = marker;
3676
- stamper.parentNg = this;
3677
- stamper.applySingle(exprs[i]);
4721
+ // The list key never touches the DOM.
4722
+ else if (o === 1)
4723
+ this.key = v;
4724
+
4725
+ // Everything else (attributes, disabled delegation, odd values) goes through
4726
+ // the shared stamper's full applySingle() semantics.
4727
+ else {
4728
+ let stamper = stampers[i];
4729
+ stamper.nodeMarker = slots[slotIdx[i]];
4730
+ stamper.parentNg = this;
4731
+ stamper.applySingle(v);
4732
+ }
3678
4733
  }
3679
4734
 
3680
4735
  this.nodesCache = null;
@@ -3688,7 +4743,7 @@ class NodeGroup {
3688
4743
  * @return {boolean} False when a child-node expression isn't primitive; the caller
3689
4744
  * must then materialize paths and apply normally. */
3690
4745
  rewriteStamp(template) {
3691
- let shell = Shell.get(template.html, template.svgMode);
4746
+ let shell = this.shell;
3692
4747
  let newExprs = template.exprs;
3693
4748
  let nodesIdx = shell.nodesPathIdx;
3694
4749
  for (let i=0; i<nodesIdx.length; i++) {
@@ -3698,19 +4753,29 @@ class NodeGroup {
3698
4753
  }
3699
4754
 
3700
4755
  let oldExprs = this.template.exprs;
3701
- let paths = shell.paths, stampers = shell.stampPaths;
3702
- let slots = null; // Nodes are resolved only if something actually changed.
3703
- for (let i = paths.length - 1; i >= 0; i--) {
3704
- if (!exprSame(oldExprs[i], newExprs[i])) {
4756
+ let stampers = shell.stampPaths, slotIdx = shell.stampSlot, flags = shell.stampFlags;
4757
+ let slots = this.stampSlotsCache; // Nodes are resolved only if something actually changed, then cached.
4758
+ for (let i = stampers.length - 1; i >= 0; i--) {
4759
+ // Live HTML properties (checked etc., boolean-valued) are exempt from the
4760
+ // unchanged-value skip: a user's click flips the DOM property underneath the cached
4761
+ // expression, and applySingle() compares against the live node before writing.
4762
+ // The identity test is inline because most expressions are unchanged, and reaching
4763
+ // exprSame() only to be told so costs more than the comparison itself.
4764
+ let oldExpr = oldExprs[i], newExpr = newExprs[i];
4765
+ let flag = flags[i];
4766
+ if ((oldExpr !== newExpr && !exprSame(oldExpr, newExpr))
4767
+ || ((flag & 1) && typeof newExpr === 'boolean')) {
4768
+ // .slice() is required: resolveStampSlots returns the Shell's SHARED scratch
4769
+ // array, which the next row's resolve would overwrite.
3705
4770
  if (slots === null)
3706
- slots = this.resolveStampSlots(shell);
4771
+ slots = this.stampSlotsCache = this.resolveStampSlots(shell).slice();
3707
4772
  let stamper = stampers[i];
3708
- let marker = slots[paths[i].markerSlot];
4773
+ let marker = slots[slotIdx[i]]; // The flat slot array, so the Path isn't loaded.
3709
4774
 
3710
4775
  // Fast path for a wholeParent text path whose child already exists (the common
3711
4776
  // rewrite case): set its value directly, skipping applySingle's branching and
3712
4777
  // textNode bookkeeping. exprSame above already proved it changed.
3713
- if (stamper.wholeParent) {
4778
+ if (flag & 2) {
3714
4779
  let v = newExprs[i], tn = marker.firstChild;
3715
4780
  if (typeof v === 'number')
3716
4781
  v += '';
@@ -3745,16 +4810,10 @@ class NodeGroup {
3745
4810
  * @return {Node[]} The shell's shared scratch slots array. */
3746
4811
  resolveStampSlots(shell) {
3747
4812
  let slots = shell.resolveSlots;
4813
+ // A singleRoot shell's first op pair is always [0, 0], so slot 1 is the row's own root
4814
+ // element and the program can start at the second pair.
3748
4815
  slots[1] = this.startNode;
3749
- let ops = shell.resolveOps;
3750
- // firstChild/nextSibling pointer walk; see setPathsFromFragment for why not childNodes[i].
3751
- for (let i=2, s=2; i<ops.length; i+=2, s++) {
3752
- let node = slots[ops[i]].firstChild;
3753
- for (let k=ops[i+1]; k>0; k--)
3754
- node = node.nextSibling;
3755
- slots[s] = node;
3756
- }
3757
- return slots;
4816
+ return runResolveOps(slots, shell.resolveOps, 2, 2);
3758
4817
  }
3759
4818
 
3760
4819
  /**
@@ -3764,17 +4823,8 @@ class NodeGroup {
3764
4823
  * @param shell {?Shell}
3765
4824
  * @return {Path[]} */
3766
4825
  materializePaths(shell=null) {
3767
- shell ??= Shell.get(this.template.html, this.template.svgMode);
3768
- let slots = this.resolveStampSlots(shell);
3769
- let paths = shell.paths;
3770
- let pathLength = paths.length;
3771
- let result = this.paths = new Array(pathLength);
3772
- for (let i=0; i<pathLength; i++) {
3773
- let p = paths[i];
3774
- let path = p.cloneWithNodes(p.beforeSlot >= 0 ? slots[p.beforeSlot] : null, slots[p.markerSlot]);
3775
- path.parentNg = this;
3776
- result[i] = path;
3777
- }
4826
+ shell ??= this.shell;
4827
+ let result = this.clonePathsFromSlots(shell, this.resolveStampSlots(shell));
3778
4828
 
3779
4829
  // A wholeParent child-node path that stamped a primitive left exactly one Text child.
3780
4830
  for (let idx of shell.nodesPathIdx) {
@@ -3814,14 +4864,8 @@ class NodeGroup {
3814
4864
  /**
3815
4865
  * Get the root element of the NodeGroup's RootNodeGroup.
3816
4866
  * @returns {HTMLElement|DocumentFragment} */
3817
- getRootNode() {
3818
- return this.rootNg.root;
3819
- }
3820
-
3821
- /**
3822
- * @returns {RootNodeGroup} */
3823
- getRootNodeGroup() {
3824
- return this.rootNg;
4867
+ getRootEl() {
4868
+ return this.rootNg.rootEl;
3825
4869
  }
3826
4870
 
3827
4871
  /**
@@ -3832,9 +4876,6 @@ class NodeGroup {
3832
4876
  * @param isRootClone {boolean} True when fragment is a direct clone of a singleRoot
3833
4877
  * shell's root element: it fills slot 1 itself and the first op pair is skipped. */
3834
4878
  setPathsFromFragment(fragment, shell, startingPathDepth=0, isRootClone=false) {
3835
- let paths = shell.paths;
3836
- let pathLength = paths.length; // For faster iteration
3837
- let result = this.paths = new Array(pathLength);
3838
4879
 
3839
4880
  // Fast path: run the shell's precomputed resolve program (see Shell.buildResolveProgram).
3840
4881
  // Each Path.clone() would walk childNodes from the fragment root to its target node,
@@ -3846,37 +4887,45 @@ class NodeGroup {
3846
4887
  // attribPaths behavior; pathOffset!==0 (root grafting) also uses the fallback.
3847
4888
  let ops = shell.resolveOps;
3848
4889
  if (ops && startingPathDepth === 0) {
3849
- let slots = shell.resolveSlots;
3850
- let i = 0, s = 1;
3851
- if (isRootClone) { // Slot 1 is the root element itself; skip its op pair.
3852
- slots[1] = fragment;
3853
- i = 2;
3854
- s = 2;
3855
- }
3856
- else
4890
+ let slots;
4891
+ if (isRootClone) // The root element is also this.startNode, so it seeds slot 1 itself.
4892
+ slots = this.resolveStampSlots(shell);
4893
+ else {
4894
+ slots = shell.resolveSlots;
3857
4895
  slots[0] = fragment;
3858
- // Resolve each node via firstChild/nextSibling pointer walks instead of
3859
- // childNodes[index]; the live NodeList indexing is markedly slower, and indices
3860
- // are small (markers are elements, often the first child after whitespace stripping).
3861
- for (; i<ops.length; i+=2, s++) {
3862
- let node = slots[ops[i]].firstChild;
3863
- for (let k=ops[i+1]; k>0; k--)
3864
- node = node.nextSibling;
3865
- slots[s] = node;
3866
- }
3867
- for (let i=0; i<pathLength; i++) {
3868
- let p = paths[i];
3869
- let path = p.cloneWithNodes(p.beforeSlot >= 0 ? slots[p.beforeSlot] : null, slots[p.markerSlot]);
3870
- path.parentNg = this;
3871
- result[i] = path;
4896
+ runResolveOps(slots, ops, 0, 1);
3872
4897
  }
4898
+ this.clonePathsFromSlots(shell, slots);
3873
4899
  }
3874
- else
4900
+ else {
4901
+ let paths = shell.paths;
4902
+ let pathLength = paths.length; // For faster iteration
4903
+ let result = this.paths = new Array(pathLength);
3875
4904
  for (let i=0; i<pathLength; i++) {
3876
4905
  let path = paths[i].clone(fragment, startingPathDepth);
3877
4906
  path.parentNg = this;
3878
4907
  result[i] = path;
3879
4908
  }
4909
+ }
4910
+ }
4911
+
4912
+ /**
4913
+ * Copy the shell's Paths onto this NodeGroup's own nodes, taking each path's marker and
4914
+ * before-node from the slots the resolve program just filled.
4915
+ * @param shell {Shell}
4916
+ * @param slots {Node[]} The shell's shared scratch slots, already resolved.
4917
+ * @return {Path[]} */
4918
+ clonePathsFromSlots(shell, slots) {
4919
+ let paths = shell.paths;
4920
+ let pathLength = paths.length;
4921
+ let result = this.paths = new Array(pathLength);
4922
+ for (let i=0; i<pathLength; i++) {
4923
+ let p = paths[i];
4924
+ let path = p.cloneWithNodes(p.beforeSlot >= 0 ? slots[p.beforeSlot] : null, slots[p.markerSlot]);
4925
+ path.parentNg = this;
4926
+ result[i] = path;
4927
+ }
4928
+ return result;
3880
4929
  }
3881
4930
 
3882
4931
  updateStyles() {
@@ -3884,7 +4933,7 @@ class NodeGroup {
3884
4933
  for (let [style, oldText] of this.styles) {
3885
4934
  let newText = style.textContent;
3886
4935
  if (oldText !== newText)
3887
- Util.bindStyles(style, this.getRootNodeGroup().root);
4936
+ Util.bindStyles(style, this.rootNg.rootEl);
3888
4937
  }
3889
4938
  }
3890
4939
 
@@ -3894,16 +4943,14 @@ class NodeGroup {
3894
4943
  * @param pathOffset {int} */
3895
4944
  activateEmbeds(root, shell, pathOffset=0) {
3896
4945
 
3897
- let rootEl = this.rootNg.root;
4946
+ let rootEl = this.rootNg.rootEl;
3898
4947
  if (rootEl) {
3899
- let options = this.rootNg.options;
4948
+ let options = this.rootNg.renderOptions;
3900
4949
 
3901
4950
  // ids
3902
4951
  if (options?.ids !== false) {
3903
4952
  for (let path of shell.ids) {
3904
- if (pathOffset)
3905
- path = path.slice(0, -pathOffset);
3906
- let el = Path.resolve(root, path);
4953
+ let el = Path.resolve(root, path, pathOffset);
3907
4954
  Util.bindId(rootEl, el);
3908
4955
  }
3909
4956
  }
@@ -3913,11 +4960,8 @@ class NodeGroup {
3913
4960
  if (shell.styles.length)
3914
4961
  this.styles = new Map();
3915
4962
  for (let path of shell.styles) {
3916
- if (pathOffset)
3917
- path = path.slice(0, -pathOffset);
3918
-
3919
4963
  /** @type {HTMLStyleElement} */
3920
- let style = Path.resolve(root, path);
4964
+ let style = Path.resolve(root, path, pathOffset);
3921
4965
  if (rootEl.nodeType === 1) {
3922
4966
  Util.bindStyles(style, rootEl);
3923
4967
  this.styles.set(style, style.textContent);
@@ -3928,9 +4972,7 @@ class NodeGroup {
3928
4972
  // scripts
3929
4973
  if (options?.scripts !== false) {
3930
4974
  for (let path of shell.scripts) {
3931
- if (pathOffset)
3932
- path = path.slice(0, -pathOffset);
3933
- let script = Path.resolve(root, path);
4975
+ let script = Path.resolve(root, path, pathOffset);
3934
4976
  // Indirect eval runs in global scope (correct for a <script> tag) and, unlike a direct
3935
4977
  // eval, doesn't force terser to keep every top-level name in the bundle unmangled.
3936
4978
  (0, eval)(script.textContent);
@@ -3939,7 +4981,7 @@ class NodeGroup {
3939
4981
  }
3940
4982
  }
3941
4983
 
3942
- //#IFDEV
4984
+ //#IFDEBUG
3943
4985
  getParentNode() {
3944
4986
  return this.startNode?.parentNode
3945
4987
  }
@@ -4009,8 +5051,8 @@ class NodeGroup {
4009
5051
  * Has these properties not present on NodeGroup, assigned by instantiate():
4010
5052
  * They're not declared as fields because subclass field initializers run after the
4011
5053
  * super constructor and would overwrite the assigned values.
4012
- * @property {HTMLElement} root - Root node at the top of the hierarchy.
4013
- * @property {?object} options - RenderOptions */
5054
+ * @property {HTMLElement} rootEl - Root node at the top of the hierarchy.
5055
+ * @property {?object} renderOptions - RenderOptions */
4014
5056
  class RootNodeGroup extends NodeGroup {
4015
5057
 
4016
5058
  /**
@@ -4019,19 +5061,19 @@ class RootNodeGroup extends NodeGroup {
4019
5061
  * Called by the NodeGroup constructor. */
4020
5062
  instantiate(shell, shellFragment, el, options) {
4021
5063
  let startingPathDepth = 0;
4022
- this.options = options;
5064
+ this.renderOptions = options;
4023
5065
  if (shellFragment instanceof Text) {
4024
5066
  if (!el)
4025
- throw new Error('Cannot create a standalone text node');
5067
+ throw new Error('Text node needs an element.');
4026
5068
 
4027
- this.root = el;
5069
+ this.rootEl = el;
4028
5070
  if (shellFragment.nodeValue.length)
4029
- this.root.append(shellFragment);
5071
+ this.rootEl.append(shellFragment);
4030
5072
  }
4031
5073
 
4032
5074
  else {
4033
5075
  if (el) {
4034
- this.root = el;
5076
+ this.rootEl = el;
4035
5077
 
4036
5078
  // Save slot
4037
5079
  // 1. Globals.currentSlotChildren is set if this is called via PathToComponent.applyComponent() calls render()
@@ -4043,13 +5085,13 @@ class RootNodeGroup extends NodeGroup {
4043
5085
  }
4044
5086
 
4045
5087
  // If el should replace the root node of the fragment.
4046
- if (isReplaceEl(shellFragment, this.root.tagName)) {
4047
- this.root.append(...shellFragment.children[0].childNodes);
5088
+ if (isReplaceEl(shellFragment, this.rootEl.tagName)) {
5089
+ this.rootEl.append(...shellFragment.children[0].childNodes);
4048
5090
 
4049
5091
  // Copy attributes
4050
5092
  for (let attrib of shellFragment.children[0].attributes)
4051
- if (!this.root.hasAttribute(attrib.name))
4052
- this.root.setAttribute(attrib.name, attrib.value);
5093
+ if (!this.rootEl.hasAttribute(attrib.name))
5094
+ this.rootEl.setAttribute(attrib.name, attrib.value);
4053
5095
 
4054
5096
  // Go one level deeper into all of shell's paths.
4055
5097
  startingPathDepth = 1;
@@ -4058,7 +5100,7 @@ class RootNodeGroup extends NodeGroup {
4058
5100
  else {
4059
5101
  let isEmpty = shellFragment.childNodes.length === 1 && shellFragment.childNodes[0].nodeType === 3 && shellFragment.childNodes[0].textContent === '';
4060
5102
  if (!isEmpty)
4061
- this.root.append(...shellFragment.childNodes);
5103
+ this.rootEl.append(...shellFragment.childNodes);
4062
5104
  }
4063
5105
 
4064
5106
 
@@ -4084,34 +5126,26 @@ class RootNodeGroup extends NodeGroup {
4084
5126
 
4085
5127
  // Instantiate as a standalone element.
4086
5128
  else {
4087
- let onlyChild = getSingleEl(shellFragment);
4088
- this.root = onlyChild || shellFragment; // We return the whole fragment when calling h() with a collection of nodes.
5129
+ // Trimming the whitespace and comment nodes off both ends leaves a list of exactly
5130
+ // one node only when the fragment has exactly one node worth keeping, which is the
5131
+ // question being asked here.
5132
+ let relevantNodes = Util.trimEmptyNodes(shellFragment.childNodes);
5133
+ let onlyChild = relevantNodes.length === 1 ? relevantNodes[0] : null;
5134
+ this.rootEl = onlyChild || shellFragment; // We return the whole fragment when calling h() with a collection of nodes.
4089
5135
  if (onlyChild)
4090
5136
  startingPathDepth = 1;
4091
5137
  }
4092
5138
 
4093
- this.setPathsFromFragment(this.root, shell, startingPathDepth);
4094
- this.activateEmbeds(this.root, shell, startingPathDepth);
5139
+ this.setPathsFromFragment(this.rootEl, shell, startingPathDepth);
5140
+ this.activateEmbeds(this.rootEl, shell, startingPathDepth);
4095
5141
  }
4096
- this.startNode = this.endNode = this.root;
5142
+ this.startNode = this.endNode = this.rootEl;
4097
5143
 
4098
- Globals$1.rootNodeGroups.set(this.root, this);
5144
+ Globals$1.rootNodeGroups.set(this.rootEl, this);
4099
5145
  }
4100
5146
  }
4101
5147
 
4102
5148
 
4103
- function getSingleEl(fragment) {
4104
- let nonempty = [];
4105
- for (let n of fragment.childNodes) {
4106
- if (n.nodeType === 1 || n.nodeType === 3 && n.textContent.trim().length) {
4107
- if (nonempty.length)
4108
- return null;
4109
- nonempty.push(n);
4110
- }
4111
- }
4112
- return nonempty[0];
4113
- }
4114
-
4115
5149
  /**
4116
5150
  * Does the fragment have one child that's an element matching the tagname of el?
4117
5151
  * @param fragment {DocumentFragment}
@@ -4174,7 +5208,7 @@ class Template {
4174
5208
 
4175
5209
  //this.trace = new Error().stack.split(/\n/g)
4176
5210
 
4177
- //#IFDEV
5211
+ //#IFDEBUG
4178
5212
  assert(Array.isArray(htmlStrings));
4179
5213
  assert(Array.isArray(exprs));
4180
5214
 
@@ -4199,8 +5233,12 @@ class Template {
4199
5233
  if (!ng) {
4200
5234
  ng = new RootNodeGroup(this, null, el, options);
4201
5235
  if (!el) // null if it's a standalone elment.
4202
- el = ng.getRootNode();
4203
- Globals$1.rootNodeGroups.set(el, ng); // All tests still pass if this is commented out!
5236
+ el = ng.getRootEl();
5237
+
5238
+ // RootNodeGroup.instantiate() ends by registering itself under its own rootEl, which
5239
+ // is the element we were given, or -- when we were given none -- the very element
5240
+ // getRootEl() just handed back. Registering it a second time here stored the same
5241
+ // group under the same key.
4204
5242
  }
4205
5243
 
4206
5244
  // Make sure the expresion count matches match the Path "hole" count.
@@ -4215,8 +5253,13 @@ class Template {
4215
5253
  // If we didn't just create it, we need to render it.
4216
5254
  if (this.html?.length === 1 && !this.html[0]) // An empty string.
4217
5255
  el.innerHTML = ''; // Fast path for empty component.
4218
- else
4219
- ng.applyExprs(this.exprs);
5256
+ else {
5257
+ // A component renders the same template every time, so hand over the expressions it
5258
+ // applied last time; paths that can prove an unchanged expression is a no-op skip.
5259
+ let last = ng.template;
5260
+ ng.applyExprs(this.exprs, true, last !== this && last.html === this.html ? last.exprs : null);
5261
+ ng.template = this;
5262
+ }
4220
5263
 
4221
5264
  return el;
4222
5265
  }
@@ -4246,9 +5289,13 @@ class Template {
4246
5289
  function templatesSame(a, b) {
4247
5290
  if (a.html === b.html && a.svgMode === b.svgMode) {
4248
5291
  let ae = a.exprs, be = b.exprs;
4249
- for (let i=0; i<ae.length; i++)
4250
- if (!exprSame(ae[i], be[i]))
5292
+ // Most expressions are identical between renders, so test that here rather than paying
5293
+ // a call into exprSame() to learn it.
5294
+ for (let i=0; i<ae.length; i++) {
5295
+ let x = ae[i], y = be[i];
5296
+ if (x !== y && !exprSame(x, y))
4251
5297
  return false;
5298
+ }
4252
5299
  return true;
4253
5300
  }
4254
5301
 
@@ -4354,7 +5401,7 @@ function toEl(arg) {
4354
5401
  let obj = arg;
4355
5402
 
4356
5403
  if (obj.constructor.name !== 'Object')
4357
- throw new Error(`Solarate Web Component class ${obj.constructor?.name} must extend HTMLElement.`);
5404
+ throw new Error(`Solarite web component class ${obj.constructor?.name} must extend HTMLElement.`);
4358
5405
 
4359
5406
  // Normal path
4360
5407
  if (!Globals$1.objToEl.has(obj)) {
@@ -4442,7 +5489,11 @@ const renderTemplateKey = Symbol('solariteRender');
4442
5489
  // Using `arguments` alongside rest params would force the engine to materialize both per call.
4443
5490
  const noArg = Symbol();
4444
5491
 
4445
- function h(htmlStrings=noArg, ...exprs) {
5492
+ // The /** @type {*} */ cast on the default keeps TypeScript from inferring the parameter as
5493
+ // `symbol` from noArg: TS can't parse the closure-style @param type above (function() without
5494
+ // a return type under noImplicitAny), falls back to the default's type, and then flags every
5495
+ // h(this) / h`` call in the codebase as an error. JetBrains reads the @param fine either way.
5496
+ function h(htmlStrings=/** @type {*} */(noArg), ...exprs) {
4446
5497
 
4447
5498
  // 1. Tagged template: h`<div>...</div>`
4448
5499
  if (Array.isArray(htmlStrings)) {
@@ -4495,11 +5546,14 @@ function h(htmlStrings=noArg, ...exprs) {
4495
5546
  let parent = htmlStrings, options = exprs[0];
4496
5547
 
4497
5548
  // The closure is cached on the element so repeated renders don't recreate it.
4498
- if (options === undefined) {
4499
- let cached = parent[renderTemplateKey];
4500
- if (cached)
4501
- return cached;
4502
- }
5549
+ // Options are cached with it: they only take effect when the element's
5550
+ // RootNodeGroup is first created, so a later render passing different ones is
5551
+ // ignored either way, and caching regardless of them saves an allocation on every
5552
+ // render of a component that passes an options object — which is how render() is
5553
+ // usually written.
5554
+ let cached = parent[renderTemplateKey];
5555
+ if (cached)
5556
+ return cached;
4503
5557
 
4504
5558
  // Return a tagged template function that applies the tagged template to parent.
4505
5559
  let renderTemplate = (htmlStrings, ...exprs) => {
@@ -4511,8 +5565,7 @@ function h(htmlStrings=noArg, ...exprs) {
4511
5565
  let template = new Template(htmlStrings, exprs);
4512
5566
  return template.render(parent, options);
4513
5567
  };
4514
- if (options === undefined)
4515
- parent[renderTemplateKey] = renderTemplate;
5568
+ parent[renderTemplateKey] = renderTemplate;
4516
5569
  return renderTemplate;
4517
5570
  }
4518
5571
  }
@@ -4529,11 +5582,11 @@ function h(htmlStrings=noArg, ...exprs) {
4529
5582
  // Intercepts the main h(this)`...` function call inside render().
4530
5583
  // TODO: This path doesn't handle embeds like data-id="..."
4531
5584
  else if (typeof htmlStrings === 'object' && Globals$1.objToEl.has(htmlStrings)) {
5585
+ // The only thing that ever puts an object into objToEl is toEl(), and it rejects anything
5586
+ // that isn't a plain object before it does so, so an object that reaches here has already
5587
+ // been checked and re-checking it can never report anything.
4532
5588
  let obj = htmlStrings;
4533
5589
 
4534
- if (obj.constructor.name !== 'Object')
4535
- throw new Error(`Solarate Web Component class ${obj.constructor?.name} must extend HTMLElement.`);
4536
-
4537
5590
  // Jsx with h(this, <jsx>)
4538
5591
  if (exprs[0] instanceof Template) {
4539
5592
  let template = exprs[0];
@@ -4557,14 +5610,6 @@ function h(htmlStrings=noArg, ...exprs) {
4557
5610
  throw new Error('h() does not support argument of type: ' + (htmlStrings ? typeof htmlStrings : htmlStrings))
4558
5611
  }
4559
5612
 
4560
- // h.map caches each item's Template keyed by the item's identity, so a re-render returns
4561
- // the SAME Template instance for any item whose reference is unchanged. The reconciler's
4562
- // `ng.template === item` fast path (PathToNodes.applyKeyed/applyDiff) then skips rebuilding
4563
- // and comparing that row. A WeakMap is used instead of a symbol property so the idiomatic
4564
- // immutable update `{...item, x}` yields a fresh object that ISN'T in the cache and re-renders;
4565
- // a symbol property would be copied by spread and silently reuse the stale Template.
4566
- const mapCache = new WeakMap();
4567
-
4568
5613
  /**
4569
5614
  * Render a list, reusing each item's DOM for as long as the item is the SAME object.
4570
5615
  *
@@ -4581,37 +5626,73 @@ const mapCache = new WeakMap();
4581
5626
  *
4582
5627
  * ${h.map(this.rows, row => h`<tr key=${row.id}>${row.label}</tr>`)}
4583
5628
  *
5629
+ * What comes back is a MappedList, not an array: it carries the items and the callback so
5630
+ * the reconciler can match a row to its item by identity and call the callback only for the
5631
+ * rows it can't match. Put it straight into a template expression, as above; nested inside
5632
+ * an array, or returned from a function, it expands to Templates just the same.
5633
+ *
4584
5634
  * @param items {Array} The list to render.
4585
5635
  * @param fn {function(item:*):Template} Builds an item's Template; called only for new items.
4586
- * @return {Template[]} */
4587
- h.map = (items, fn) => {
4588
- let result = new Array(items.length);
4589
- for (let i=0; i<items.length; i++) {
4590
- let item = items[i];
4591
- if (item !== null && typeof item === 'object') {
4592
- let template = mapCache.get(item);
4593
- if (template === undefined) {
4594
- template = fn(item);
4595
- mapCache.set(item, template);
4596
- }
4597
- result[i] = template;
4598
- }
4599
- else
4600
- result[i] = fn(item);
4601
- }
4602
- return result;
4603
- };
5636
+ * @return {MappedList} */
5637
+ h.map = (items, fn) => new MappedList(items, fn);
4604
5638
 
4605
5639
  h.immutableMap = h.map;
4606
5640
 
4607
- /*
4608
- ┏┓ ┓ •
4609
- ┗┓┏┓┃┏┓┏┓┓╋▗▖
4610
- ┗┛┗┛┗┗┻╹ ╹╹┗
4611
- JavaScript UI library
4612
- @license MIT
4613
- @copyright Vorticode LLC
4614
- https://vorticode.github.io/solarite/ */
5641
+ /**
5642
+ * Create a selection that updates only the rows it affects.
5643
+ *
5644
+ * A highlight that moves from one row of a thousand to another changes two attributes.
5645
+ * Expressing it as ordinary state means calling render() and letting the reconciler walk the
5646
+ * list to rediscover that. A selector writes those two attributes directly instead:
5647
+ *
5648
+ * class Table extends Solarite {
5649
+ * selected = h.selector();
5650
+ *
5651
+ * pick(row) {
5652
+ * this.selected.set(row.id); // no render() call
5653
+ * }
5654
+ *
5655
+ * render() {
5656
+ * h(this)`<tbody>${h.map(this.rows, row =>
5657
+ * h`<tr key=${row.id} class=${this.selected.when(row.id, 'danger')}
5658
+ * onclick=${[this.pick, row]}>${row.label}</tr>`)}</tbody>`;
5659
+ * }
5660
+ * }
5661
+ *
5662
+ * when() must be a whole attribute value, not part of one and not element content, since it
5663
+ * owns that attribute for as long as the row exists. An off value of '' leaves no attribute
5664
+ * behind at all. Selection state lives on the selector, so it survives re-renders, and
5665
+ * set() is safe to call whether or not the rows are currently rendered.
5666
+ *
5667
+ * Two rules follow from how set() finds a row, and both throw a clear error rather than
5668
+ * misbehaving quietly. **The rows must be keyed** — set() locates a row by looking its key
5669
+ * up in the list, so the row template needs a key=${...}. And **the attribute must sit on
5670
+ * the row's own root element**, the same one that carries the key, because that is the
5671
+ * element set() writes. Drawing a row costs nothing either way: when() hands back one of
5672
+ * two shared objects rather than allocating anything per row, so a selector is free to
5673
+ * render over a list of any size and only a change of selection does any work.
5674
+ *
5675
+ * @param key {*} The initially selected key, or null for none.
5676
+ * @return {Selector} */
5677
+ h.selector = (key = null) => new Selector(key);
5678
+
5679
+ /**
5680
+ * Convert an attribute string with the given converter: Number, Boolean, String, Date,
5681
+ * or any function taking the string and returning a value. Boolean is true for any string
5682
+ * except 'false' and '0', so a bare attribute like `<my-timer auto-start>` reads as true.
5683
+ * Date uses new Date(value). No converter returns the string unchanged. */
5684
+ function convertType(value, type) {
5685
+ if (type === Date)
5686
+ return new Date(value);
5687
+ if (type === Boolean)
5688
+ return !['false', '0'].includes(value);
5689
+ // Number and String need no cases of their own: they're plain functions, so the custom
5690
+ // branch below calls them correctly. Date and Boolean are the ones that can't fall through
5691
+ // (Date without `new` returns a string; Boolean('false') is true).
5692
+ if (type) // Number, String, or a custom string=>value function
5693
+ return type(value);
5694
+ return value;
5695
+ }
4615
5696
 
4616
5697
  /**
4617
5698
  * Read an element's html attributes onto fields that already exist on the element.
@@ -4647,16 +5728,8 @@ function assignAttributes(dest, types={}, ignore=[]) {
4647
5728
  dest[name] = JSON.parse(value.slice(2, -1));
4648
5729
 
4649
5730
  // 2. Cast the string with the converter named in `types`, if any.
4650
- else if (type === Date)
4651
- dest[name] = new Date(value);
4652
- else if (type === Boolean)
4653
- dest[name] = !['false', '0'].includes(value);
4654
- else if (type === Number)
4655
- dest[name] = Number(value);
4656
- else if (type === String)
4657
- dest[name] = String(value);
4658
- else if (type) // custom string=>value function
4659
- dest[name] = type(value);
5731
+ else if (type)
5732
+ dest[name] = convertType(value, type);
4660
5733
 
4661
5734
  // 3. No converter named: assign the raw string. But an empty value over a function/object
4662
5735
  // field is just the serialization residue of a template expression (functions render as
@@ -4706,42 +5779,46 @@ let HTMLElementAutoDefine = new Proxy(HTMLElement, {
4706
5779
  class Solarite extends HTMLElementAutoDefine {
4707
5780
 
4708
5781
  /**
4709
- * @param attribs {?Record<string, any>} */
4710
- constructor(attribs=null) {
5782
+ * Fill in and fix up the attribs object a component's constructor receives, so the component
5783
+ * can then copy those values onto its own fields, e.g. with ObjectUtil.assign(this, attribs).
5784
+ *
5785
+ * 1. If attribs is an empty object, fill it with the attributes on the DOM element.
5786
+ * This happens when the browser creates the element from plain html, because then nothing
5787
+ * calls the constructor with arguments. Attribute names convert from dash-case to
5788
+ * camelCase, and `${...}` values are parsed from JSON.
5789
+ * 2. If types is given, convert attribs values from strings to those types. Attribute values
5790
+ * written as literal text always arrive as strings, whether from plain html or from an h()
5791
+ * template. types maps a field name to Number, Boolean, String, Date, or any function
5792
+ * taking the string and returning a value. Boolean is true for every string except
5793
+ * 'false' and '0', so a bare attribute like `<select-box-3 editable>` becomes true.
5794
+ * Values that are already not strings, like a `${true}` template expression, are left alone.
5795
+ *
5796
+ * This runs before the subclass initializes its fields and renders, so converted values are
5797
+ * right the first time, even for fields that change what render() builds. This constructor
5798
+ * can't copy attribs onto fields itself, because subclass field initializers run after it
5799
+ * finishes and would overwrite them; that's why the subclass does the final assign.
5800
+ * @param attribs {?Record<string, any>}
5801
+ * @param types {?Record<string, Function>} */
5802
+ constructor(attribs=null, types=null) {
4711
5803
  super();
4712
5804
 
4713
5805
  if (attribs) {
4714
5806
  if (typeof attribs !== 'object')
4715
- throw new Error('First argument to custom element constructor must be an object.');
5807
+ throw new Error('First argument must be an object.');
4716
5808
 
4717
5809
  // 1. Populate attribs if it's an empty object.
4718
- if (attribs && !Object.keys(attribs).length) {
5810
+ if (!Object.keys(attribs).length) {
4719
5811
  let attribs2 = Solarite.getAttribs(this);
4720
5812
  for (let name in attribs2) {
4721
5813
  attribs[name] = attribs2[name];
4722
5814
  }
4723
5815
  }
4724
5816
 
4725
- // 2. Populate fields from attribs.
4726
- // This does nothing because the fields are overwritten by the child class after this super() constructor executes.
4727
- //for (let name in attribs || {}) {
4728
- // if (name in this) {
4729
- // const descriptor = Object.getOwnPropertyDescriptor(this, name);
4730
- // if (!descriptor || descriptor.writable || descriptor.set)
4731
- // this[name] = attribs[name];
4732
- // }
4733
- //}
5817
+ // 2. Convert string values to the types the component declares.
5818
+ for (let name in types || {})
5819
+ if (typeof attribs[name] === 'string')
5820
+ attribs[name] = convertType(attribs[name], types[name]);
4734
5821
  }
4735
-
4736
- // 3. Wrap render function so it always provides the attribs argument.
4737
- // Disabled because this gives us strings for attribute values when we call render manually.
4738
- // Instead of values given from ${...} expressions.
4739
- // let originalRender = this.render;
4740
- // this.render = (attribs, changed=true) => {
4741
- // if (!attribs) // If we have to look up the attribs, we don't know if they changed or not.
4742
- // attribs = Solarite.getAttribs(this);
4743
- // originalRender.call(this, attribs, changed);
4744
- // }
4745
5822
  }
4746
5823
 
4747
5824
  'render'() {
@@ -4884,4 +5961,4 @@ class Solarite extends HTMLElementAutoDefine {
4884
5961
  }
4885
5962
 
4886
5963
  export default h;
4887
- export { Fragment, Globals$1 as Globals, Solarite, Util as SolariteUtil, Template, assignAttributes, delve, getEventBinding, h, svg, toEl };
5964
+ export { Fragment, Globals$1 as Globals, JsxAttr as InternalJsxAttr, MappedList, Selector, SelectorRef, Solarite, Util as SolariteUtil, Template, assignAttributes, convertType, delve, getEventBinding, h, jsxToTemplate as internalJsxToTemplate, svg, toEl };