solarite 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/Solarite.js CHANGED
@@ -16,8 +16,17 @@ function reset() {
16
16
  connected: new WeakSet(),
17
17
 
18
18
  /**
19
- * Set by NodeGroup.instantiateComponent()
20
- * Used by RootNodeGroup.getSlotChildren(). */
19
+ * A hand-off in flight from PathToComponent.applyAll(), which parks the child nodes
20
+ * declared inside a component's tag here just before constructing it, to that
21
+ * component's RootNodeGroup.instantiate(), which puts them in its <slot>. Null when
22
+ * no hand-off is pending.
23
+ *
24
+ * It is addressed by Constructor rather than by tag name because a customized
25
+ * built-in has no usable tag at the moment it is consumed: a <tr is="my-row">
26
+ * reports a tagName of TR, and its 'is' attribute is not written until after the
27
+ * constructor -- which may already have rendered -- has returned.
28
+ *
29
+ * @type {?{Constructor:Function, nodes:Node[]}} */
21
30
  currentSlotChildren: null,
22
31
 
23
32
  div: document.createElement("div"),
@@ -57,6 +66,17 @@ function reset() {
57
66
  }
58
67
  reset();
59
68
 
69
+ // Warn when a second copy of Solarite loads into the same page. Each copy has its own classes and its own Globals,
70
+ // so a template or component made by one is not recognised by the other, and the failure that follows (a template
71
+ // rendered as "[object Object]", a slot that stays empty) gives no hint of the cause. The usual ways to get two are
72
+ // importing both Solarite.js and Solarite.min.js, or a JSX runtime file that doesn't match the build being imported.
73
+ // The marker is the same for every build, so the source, debug, and minified builds all detect one another.
74
+ let copy = Symbol.for('solarite');
75
+ if (globalThis[copy])
76
+ console.warn(`Solarite loaded twice: ${globalThis[copy]} and ${import.meta.url}. Templates and components from one won't work in the other.`);
77
+ else
78
+ globalThis[copy] = import.meta.url;
79
+
60
80
  var Globals$1 = Globals;
61
81
 
62
82
  /**
@@ -116,6 +136,12 @@ function isDelvePath(arr) {
116
136
  // d means "don't create"
117
137
  let d = {};
118
138
 
139
+ /**
140
+ * Prefix that asks for a handler to bypass event delegation: `<button native:onclick=\${...}>`
141
+ * is bound with addEventListener at render time, taking its normal place in the browser's own
142
+ * dispatch order. Shared by Util.isEvent() and PathToEvent, which strips it. */
143
+ const nativeEventPrefix = 'native:';
144
+
119
145
  let Util = {
120
146
 
121
147
  /**
@@ -155,13 +181,15 @@ let Util = {
155
181
  // Don't clobber a non-element value. For a simple (non-nested) id this covers two cases:
156
182
  // an inherited/built-in property like `title` or `style`, or an own property that already
157
183
  // holds a non-Node value. A previously-bound element (a Node) is fine to re-assign.
184
+ // This can only fail on a mistake in the component's own template, so a developer meets it
185
+ // the first time the component renders and never again at runtime. It nonetheless SHIPS,
186
+ // and deliberately: debug-strip blocks are removed from dist/Solarite.js, which is what
187
+ // npm serves, so hiding it there would delete it for everyone, not only for production.
158
188
  if (!id.includes('.')) {
159
189
  let existing = root[id];
160
190
  let isInherited = (id in root) && !Object.hasOwn(root, id);
161
191
  if (!existing?.nodeType && (existing != null || isInherited))
162
- throw new Error(`${root.constructor.name}.${id} can't be a reference to ` +
163
- `<${el.tagName.toLowerCase()} id="${id}"> because it would clobber an existing ` +
164
- `${isInherited ? 'built-in ' : ''}property. Rename the id or the property.`);
192
+ throw new Error(`Solarite: id="${id}" would overwrite an existing ${root.constructor.name} property.`);
165
193
  }
166
194
 
167
195
  delve(root, id.split(/\./g), el);
@@ -180,29 +208,29 @@ let Util = {
180
208
  bindStyles(style, root) {
181
209
 
182
210
  let tagName = root.tagName.toLowerCase();
183
- let styleId, attribSelector;
211
+
212
+ // A global style is scoped by tag name alone, so it needs no attribute in the selector.
213
+ let attribSelector = '';
184
214
 
185
215
  if (style.hasAttribute('global') || style.hasAttribute('data-global')) {
186
- styleId = tagName;
187
- attribSelector = '';
188
- let doc = Globals$1.doc || root.ownerDocument || document;
189
- if (!doc.head.querySelector(`style[data-style="${styleId}"]`)) {
190
- doc.head.append(style);
191
- style.setAttribute('data-style', styleId);
192
- }
193
- else // TODO: Make sure the style has no expressions.
216
+ let head = Globals$1.doc.head;
217
+ if (head.querySelector(`style[data-style="${tagName}"]`))
218
+ // TODO: Make sure the style has no expressions.
194
219
  style.remove(); // already in the head.
220
+ else {
221
+ head.append(style);
222
+ style.setAttribute('data-style', tagName);
223
+ }
195
224
  }
196
225
  else {
197
226
  let styleId = root.getAttribute('data-style');
198
227
  if (!styleId) {
199
- // Keep track of one style id for each class.
228
+ // Keep track of one style id for each class. Reading the static walks up to a parent
229
+ // class's counter if this class has never been styled, but the assignment always lands
230
+ // on this class, so each class then counts on from where its parent left off.
200
231
  // TODO: Put this outside the class in a map, so it doesn't conflict with static properties.
201
- if (!root.constructor.styleId)
202
- root.constructor.styleId = 1;
203
- styleId = root.constructor.styleId++;
204
-
205
- root.setAttribute('data-style', styleId);
232
+ let Class = root.constructor;
233
+ root.setAttribute('data-style', styleId = Class.styleId = (Class.styleId || 0) + 1);
206
234
  }
207
235
 
208
236
  attribSelector = `[data-style="${styleId}"]`;
@@ -212,7 +240,19 @@ let Util = {
212
240
  for (let child of style.childNodes) {
213
241
  if (child.nodeType === 3) {
214
242
  let oldText = child.textContent;
215
- let newText = oldText.replace(/:host(?=[^a-z0-9_])/gi, `${tagName}${attribSelector}`);
243
+
244
+ // One pass rewrites both forms of the selector:
245
+ // 1. The functional form ':host(X)' — the host element when it also matches X — unwraps
246
+ // so X sits right after the scoped name: tag[data-style="1"]X. X may hold one
247
+ // nested group like ':not(.open)'; deeper parentheses can't be paired by a regex,
248
+ // so such an X is left as written rather than half-rewritten into a selector the
249
+ // browser would discard silently.
250
+ // 2. Plain ':host'. The lookahead turns down longer names (':host-context') and '(',
251
+ // which only follows ':host' when alternative 1 already gave up on it, and accepts
252
+ // the end of the text node, where an expression may have split a dynamic style.
253
+ let newText = oldText.replace(
254
+ /:host(?:\(((?:[^()]|\([^()]*\))*)\)|(?![-a-z0-9_(]))/gi,
255
+ `${tagName}${attribSelector}$1`);
216
256
  if (oldText !== newText)
217
257
  child.textContent = newText;
218
258
  }
@@ -233,17 +273,15 @@ let Util = {
233
273
  * 'UIForm' => 'ui-form'
234
274
  * 'A100' => 'a-100' */
235
275
  camelToDashes(str) {
236
- // Convert any capital letter that is preceded by a lowercase letter or number to lowercase and precede with a dash.
237
- str = str.replace(/([a-z0-9])([A-Z])/g, '$1-$2');
238
-
239
- // Convert any capital letter that is followed by a lowercase letter or number to lowercase and precede with a dash.
240
- str = str.replace(/([A-Z])([A-Z][a-z])/g, '$1-$2');
241
-
242
- // Convert any number that is preceded by a lowercase or uppercase letter to be preceded by a dash.
243
- str = str.replace(/([a-zA-Z])([0-9])/g, '$1-$2');
244
-
245
- // Convert all the remaining capital letters to lowercase.
246
- return str.toLowerCase();
276
+ // One pass finds all three dash positions. Each alternative matches only the character
277
+ // *before* the boundary and uses a lookahead for what follows, so the following character
278
+ // is never consumed and can still start the next boundary. That's what lets the three
279
+ // rules interleave in a single scan the way three sequential replaces used to:
280
+ // 1. a lowercase letter or digit before a capital ('ProperName').
281
+ // 2. a capital before a capital+lowercase pair, i.e. the last capital of a run ('HTMLElement').
282
+ // 3. a letter before a digit ('A100').
283
+ // '$&-' appends the dash after the matched character, then everything folds to lowercase.
284
+ return str.replace(/[a-z0-9](?=[A-Z])|[A-Z](?=[A-Z][a-z])|[a-zA-Z](?=\d)/g, '$&-').toLowerCase();
247
285
  },
248
286
 
249
287
  /**
@@ -259,13 +297,24 @@ let Util = {
259
297
  return str.replace(/-([a-z])/g, g => g[1].toUpperCase());
260
298
  },
261
299
 
300
+ /**
301
+ * Register Class as a custom element, unless it's registered already.
302
+ * @param Class {typeof HTMLElement}
303
+ * @param tagName {?string} Name to register under. Defaults to the dashed form of the class name.
304
+ * @return {string} The tag name Class is registered under, whether we just registered it or it
305
+ * was already in the registry under some other name. Callers that emit markup for the class
306
+ * use this instead of re-deriving the name, which guesses wrong for any class registered
307
+ * under a name that isn't camelToDashes(Class.name). */
262
308
  defineClass(Class, tagName) {
263
- if (!customElements[getName](Class)) { // If not previously defined.
264
- tagName = tagName || Util.camelToDashes(Class.name);
265
- if (!tagName.includes('-')) // Browsers require that web components always have a dash in the name.
266
- tagName += '-element';
267
- customElements[define](tagName, Class);
268
- }
309
+ let defined = customElements[getName](Class);
310
+ if (defined) // Previously defined.
311
+ return defined;
312
+
313
+ tagName = tagName || Util.camelToDashes(Class.name);
314
+ if (!tagName.includes('-')) // Browsers require that web components always have a dash in the name.
315
+ tagName += '-element';
316
+ customElements[define](tagName, Class);
317
+ return tagName;
269
318
  },
270
319
 
271
320
  /**
@@ -292,8 +341,15 @@ let Util = {
292
341
  return node.value; // String
293
342
  },
294
343
 
295
- isEvent(attrName) {
296
- return attrName.startsWith('on') && attrName in Globals$1.div;
344
+ /**
345
+ * True for an attribute name that binds an event: `onclick`, or `native:onclick` for a
346
+ * handler that is registered with addEventListener when the template renders instead of
347
+ * being delegated. Only names an element really exposes as on* handlers count, so an
348
+ * attribute like `online` is never mistaken for one. */
349
+ isEvent(attribName) {
350
+ if (attribName.startsWith(nativeEventPrefix))
351
+ attribName = attribName.slice(nativeEventPrefix.length);
352
+ return attribName.startsWith('on') && attribName in Globals$1.div;
297
353
  },
298
354
 
299
355
  /**
@@ -330,16 +386,13 @@ let Util = {
330
386
  * @returns {Object} */
331
387
  splitAttribs(str) {
332
388
  let result = {};
333
- let attrs = (str + '') // Split string into multiple attributes.
334
- .split(/([\w-]+\s*=\s*(?:"[^"]*"|'[^']*'|\S+))/g)
335
- .map(text => text.trim())
336
- .filter(text => text.length);
337
-
338
- for (let attr of attrs) {
339
- let [name, value] = attr.split(/\s*=\s*/); // split on first equals.
340
- value = (value || '').replace(/^(['"])(.*)\1$/, '$2'); // trim value quotes if they match.
341
- result[name] = value;
342
- }
389
+
390
+ // One scan collects every name and its value. The value is optional so a boolean attribute
391
+ // written on its own ('disabled') still lands in the result with an empty value, and the three
392
+ // value alternatives capture *inside* the quotes so no separate quote-trimming pass is needed.
393
+ // Whatever doesn't look like an attribute name is skipped rather than becoming a bogus key.
394
+ (str + '').replace(/([\w-]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g,
395
+ (_, name, dq, sq, bare) => result[name] = dq ?? sq ?? bare ?? '');
343
396
 
344
397
  return result;
345
398
  },
@@ -377,19 +430,14 @@ let Util = {
377
430
  * @param nodes {Node[]|NodeList}
378
431
  * @returns {Node[]} */
379
432
  trimEmptyNodes(nodes) {
380
- const shouldTrimNode = node =>
381
- node.nodeType !== Node.ELEMENT_NODE &&
382
- (node.nodeType !== Node.TEXT_NODE || node.textContent.trim() === '');
433
+ // nodeType 1 is an element and 3 is a text node; the literals are what Node.ELEMENT_NODE
434
+ // and Node.TEXT_NODE are defined as, and they cost a fraction of the bytes.
435
+ let isEmpty = node => node.nodeType !== 1 && (node.nodeType !== 3 || !node.textContent.trim());
383
436
 
384
- // Convert nodeList to an array for easier manipulation
385
- const result = [...nodes];
386
-
387
- // Trim from the start
388
- while (result.length > 0 && shouldTrimNode(result[0]))
437
+ let result = [...nodes]; // A NodeList can't shift() or pop().
438
+ while (result.length && isEmpty(result[0]))
389
439
  result.shift();
390
-
391
- // Trim from the end
392
- while (result.length > 0 && shouldTrimNode(result[result.length - 1]))
440
+ while (result.length && isEmpty(result[result.length - 1]))
393
441
  result.pop();
394
442
 
395
443
  return result;
@@ -446,6 +494,23 @@ class Path {
446
494
  * @type {Node[]} Cached result of getNodes() */
447
495
  nodesCache;
448
496
 
497
+ /** @type {boolean|undefined} True when this path provides an attribute of a web component
498
+ * (a -solarite-placeholder element). Only attribute paths ever set it true, but it's
499
+ * declared here on every Path because clone() and cloneWithNodes() copy it to every clone;
500
+ * declaring it keeps those stores from transitioning the clone's hidden class. */
501
+ isComponentAttrib;
502
+
503
+ /** @type {boolean} True when re-applying an expression identical to the one already
504
+ * applied is provably a no-op, so a re-render can skip this path entirely. Only event
505
+ * bindings qualify: binding the same handler to the same node again changes nothing,
506
+ * while an attribute or a child expression may have been altered outside the template. */
507
+ skipIfSame = false;
508
+
509
+ /** @type {boolean|undefined} True when the attribute is a live HTML property
510
+ * (checked/value/selected — Util.isHtmlProp), which users can flip underneath the
511
+ * template. Declared here for the same hidden-class reason as isComponentAttrib. */
512
+ isHtmlProperty;
513
+
449
514
  // Set only on Shell paths, never on cloned instances, so they're not declared as
450
515
  // class fields; that would cost a store per field on every clone:
451
516
  // nodeBeforeIndex {int} Index of nodeBefore among its parentNode's children.
@@ -481,7 +546,10 @@ class Path {
481
546
  * [[expr5], [expr6, expr7]] // arguments to second my-component constructor.
482
547
  * [expr5] // user attribute value.
483
548
  * [expr6, expr7] // role attribute value. */
484
- apply(exprs) {}
549
+ applyAll(exprs) {
550
+
551
+ this.applySingle(exprs[0]);
552
+ }
485
553
 
486
554
  /**
487
555
  * Fast path used by NodeGroup.applyExprs() when every path consumes exactly one expression.
@@ -491,24 +559,12 @@ class Path {
491
559
 
492
560
  getExpressionCount() { return 1 }
493
561
 
494
-
495
562
  /**
496
- * Resolve nodeMarkerPath to new root.
497
- * TODO: Make clone() use this.*/
498
- getNewNodeMarker(newRoot, pathOffset) {
499
- let root = newRoot;
500
- let path = this.nodeMarkerPath;
501
- let pathLength = path.length - pathOffset;
502
- for (let i=pathLength-1; i>0; i--) { // Resolve the path.
503
-
504
- root = root.childNodes[path[i]];
505
- }
506
- let childNodes = root.childNodes;
507
-
508
- return pathLength
509
- ? childNodes[path[0]]
510
- : newRoot;
511
- }
563
+ * The value a path hands to a component constructor, for the single-expression paths.
564
+ * PathToAttribValue overrides this to join its surrounding static strings.
565
+ * @param exprs {Expr[]}
566
+ * @return {Expr} */
567
+ getValue(exprs) { return exprs[0] }
512
568
 
513
569
 
514
570
  /**
@@ -518,7 +574,7 @@ class Path {
518
574
  * @param nodeMarker {Node}
519
575
  * @return {Path} */
520
576
  cloneWithNodes(nodeBefore, nodeMarker) {
521
- let result = new this.constructor(nodeBefore, nodeMarker, this.attrName, this.attrValue);
577
+ let result = new this.constructor(nodeBefore, nodeMarker, this.attribName, this.attrValue);
522
578
  result.isComponentAttrib = this.isComponentAttrib;
523
579
  result.wholeParent = this.wholeParent;
524
580
  result.isHtmlProperty = this.isHtmlProperty;
@@ -532,33 +588,19 @@ class Path {
532
588
  clone(newRoot, pathOffset=0) {
533
589
 
534
590
 
535
- // Resolve node paths.
536
- let nodeMarker, nodeBefore;
537
- let root = newRoot;
538
- let path = this.nodeMarkerPath;
539
- let pathLength = path.length - pathOffset;
540
- for (let i=pathLength-1; i>0; i--) { // Resolve the path.
541
-
542
- root = root.childNodes[path[i]];
543
- }
544
- let childNodes = root.childNodes;
545
-
546
- nodeMarker = pathLength
547
- ? childNodes[path[0]]
548
- : newRoot;
591
+ // Resolve node paths. nodeBefore is always a sibling of nodeMarker (Shell builds it from
592
+ // nodeMarker.previousSibling, or inserts a comment immediately before it), so the list
593
+ // nodeBeforeIndex counts within is the marker's own parent's childNodes. An empty path
594
+ // leaves the marker as newRoot itself, and then that list is newRoot's children.
595
+ let nodeBefore;
596
+ let nodeMarker = Path.resolve(newRoot, this.nodeMarkerPath, pathOffset);
549
597
  if (this.nodeBefore) {
598
+ let childNodes = (nodeMarker === newRoot ? newRoot : nodeMarker.parentNode).childNodes;
550
599
 
551
600
  nodeBefore = childNodes[this.nodeBeforeIndex];
552
-
553
601
  }
554
602
 
555
- let result = new this.constructor(nodeBefore, nodeMarker, this.attrName, this.attrValue);
556
-
557
- result.isComponentAttrib = this.isComponentAttrib;
558
- result.wholeParent = this.wholeParent;
559
-
560
- // TODO: Put this in PathToAttribValue.clone().
561
- result.isHtmlProperty = this.isHtmlProperty;
603
+ let result = this.cloneWithNodes(nodeBefore, nodeMarker);
562
604
 
563
605
 
564
606
 
@@ -582,131 +624,250 @@ class Path {
582
624
  * Note that the path is backward, with the outermost element at the end.
583
625
  * @param root {HTMLElement|Document|DocumentFragment|ParentNode}
584
626
  * @param path {int[]}
627
+ * @param skip {int} How many of the outermost steps to leave off, for when root is
628
+ * already that many levels down from where the path was recorded. An empty walk
629
+ * (skip === path.length) returns root itself.
585
630
  * @returns {Node|HTMLElement|HTMLStyleElement} */
586
- static resolve(root, path) {
587
- for (let i=path.length-1; i>=0; i--)
631
+ static resolve(root, path, skip=0) {
632
+ for (let i=path.length-1-skip; i>=0; i--) {
633
+
588
634
  root = root.childNodes[path[i]];
635
+ }
589
636
  return root;
590
637
  }
591
638
 
592
639
 
593
640
  }
594
641
 
595
- class HtmlParser {
596
- constructor() {
597
- this.defaultState = {
598
- context: HtmlParser.Text, // possible values: 'TEXT', 'TAG', 'ATTRIBUTE'
599
- quote: null, // possible values: null, '"', "'"
600
- buffer: '',
601
- lastChar: null
602
- };
603
- this.state = {...this.defaultState};
642
+ /**
643
+ * A key-scoped selection that updates only the rows it actually affects.
644
+ *
645
+ * Rendering a list normally means calling render() and letting the reconciler decide what
646
+ * changed. That is the right default, but it is a poor fit for a selection: moving a
647
+ * highlight from one row of a thousand to another changes two attributes, and asking the
648
+ * reconciler about it means walking the whole list to discover that fact.
649
+ *
650
+ * A Selector short-circuits that. when() hands each row one of exactly two objects — the
651
+ * selected one or the unselected one — and set() reaches the two rows that change through
652
+ * the list they were rendered into, writing their attributes directly with no render() call.
653
+ *
654
+ * This is the same primitive as Solid's createSelector, adapted to a library that has no
655
+ * signals: the list, not a subscription, is what carries the binding.
656
+ *
657
+ * Because set() locates a row by its key, **the rows must be keyed** — the row template needs
658
+ * a key=${...} attribute. set() throws on an unkeyed list rather than silently doing nothing.
659
+ */
660
+
661
+ /**
662
+ * The value an attribute is bound to. There are only ever **two** of these per Selector,
663
+ * both built in its constructor: one standing for "this row is the selected one" and one for
664
+ * "this row is not". when() returns whichever of the two the row's key calls for.
665
+ *
666
+ * Two singletons rather than one object per key is what makes a selector free to create. A
667
+ * row of a freshly-drawn list with nothing selected gets the unselected singleton, whose
668
+ * value is the off value, so there is no allocation, no map entry and no DOM call — only the
669
+ * two stores that record where the list lives. It also sharpens the re-render skip: a row's
670
+ * expression changes identity exactly when its selectedness changes, so
671
+ * NodeGroup.rewriteStamp() rewrites the rows that gained or lost the selection and no others.
672
+ */
673
+ class SelectorRef {
674
+
675
+ /** @type {Selector} */
676
+ selector;
677
+
678
+ /** @type {boolean} True on the singleton that stands for the selected row. */
679
+ selected;
680
+
681
+ constructor(selector, selected) {
682
+ this.selector = selector;
683
+ this.selected = selected;
604
684
  }
605
685
 
606
- reset() {
607
- this.state = {...this.defaultState};
608
- return this.state.context;
686
+ /** @return {*} The value this ref currently stands for. */
687
+ value() {
688
+ let s = this.selector;
689
+ return this.selected ? s.onValue : s.offValue;
609
690
  }
610
691
 
611
692
  /**
612
- * Parse the next chunk of html, starting with the same context we left off with from the previous chunk.
613
- * @param html {string}
614
- * @param onContextChange {?function(html:string, index:int, prevContext:string, nextContext:string)}
615
- * Called every time the context changes, and again at the last context.
616
- * @return {('Attribute','Text','Tag')} The context at the end of html. */
617
- parse(html, onContextChange=null) {
618
- if (html === null)
619
- return this.reset();
620
-
621
- for (let i = 0; i < html.length; i++) {
622
- const char = html[i];
623
- switch (this.state.context) {
624
- case HtmlParser.Text:
625
- if (char === '<' && html[i + 1].match(/[/a-z!]/i)) { // Start of a tag or comment.
626
- onContextChange?.(html, i, this.state.context, HtmlParser.Tag);
627
- this.state.context = HtmlParser.Tag;
628
- this.state.buffer = '';
629
- }
630
- break;
631
- case HtmlParser.Tag:
632
- if (char === '>') {
633
- onContextChange?.(html, i+1, this.state.context, HtmlParser.Text);
634
- this.state.context = HtmlParser.Text;
635
- this.state.quote = null;
636
- this.state.buffer = '';
637
- }
638
- else if (char === ' ' && !this.state.buffer) {
639
- // No attribute name is present. Skipping the space.
640
- continue;
641
- }
642
- else if (char === ' ' || char === '/' || char === '?') {
643
- this.state.buffer = ''; // Reset the buffer when a delimiter or potential self-closing sign is found.
644
- }
645
- else if (char === '"' || char === "'" || char === '=') {
646
- onContextChange?.(html, i, this.state.context, HtmlParser.Attribute);
647
- this.state.context = HtmlParser.Attribute;
648
- this.state.quote = char === '=' ? null : char;
649
- this.state.buffer = '';
650
- }
651
- else
652
- this.state.buffer += char;
653
- break;
654
- case HtmlParser.Attribute:
655
- // Start an attribute quote.
656
- if (!this.state.quote && !this.state.buffer.length && (char === '"' || char === "'")) {
657
- this.state.quote = char;
658
- }
659
- else if (char === this.state.quote || (!this.state.quote && this.state.buffer.length)) {
660
- onContextChange?.(html, i, this.state.context, HtmlParser.Tag);
661
- this.state.context = HtmlParser.Tag;
662
- this.state.quote = null;
663
- this.state.buffer = '';
664
- }
665
- else if (!this.state.quote && char === '>') {
666
- onContextChange?.(html, i+1, this.state.context, HtmlParser.Text);
667
- this.state.context = HtmlParser.Text;
668
- this.state.quote = null;
669
- this.state.buffer = '';
670
- }
671
- else if (char !== ' ')
672
- this.state.buffer += char;
673
-
674
- break;
675
- }
693
+ * Write this ref's value to an element's attribute, and tell the selector where the list
694
+ * is so that a later set() can find any row in it.
695
+ *
696
+ * Called by PathToAttribValue when the ref appears as an attribute expression. It runs
697
+ * once per row per render, so it is deliberately nothing but two stores and a write that
698
+ * the common case skips.
699
+ *
700
+ * @param node {Node} The element carrying the attribute.
701
+ * @param attribName {string}
702
+ * @param parentNg {NodeGroup} The row this attribute belongs to. */
703
+ bind(node, attribName, parentNg) {
704
+ // set() writes through the row's own root element, so an attribute anywhere deeper
705
+ // would be found at bind time and then written somewhere else at set() time. Catching
706
+ // it here turns a silently misplaced attribute into a clear message. It SHIPS: it is not
707
+ // in a debug-strip block, and it must not be, because the failure it catches is silent.
708
+ if (parentNg.startNode !== node)
709
+ throw new Error(`Solarite: a selector must be on the row's root element.`);
710
+
711
+ let s = this.selector;
712
+ s.attribName = attribName;
713
+ s.path = parentNg.parentPath;
714
+
715
+ let v = this.selected ? s.onValue : s.offValue;
716
+
717
+ // Matches PathToAttribValue.applySingle: an empty or falsy value leaves no attribute
718
+ // behind, so a selector never adds markup a hand-written implementation wouldn't have.
719
+ if (v === '' || v === false || v === null || v === undefined) {
720
+ // A just-cloned row provably carries no attribute of this name yet, so the
721
+ // removeAttribute — a DOM call for every row of the list — can be skipped.
722
+ if (parentNg.firstApply !== true)
723
+ node.removeAttribute(attribName);
676
724
  }
677
- onContextChange?.(html, html.length, this.state.context, null);
678
- return this.state.context;
725
+ else
726
+ node.setAttribute(attribName, v);
679
727
  }
680
728
  }
681
729
 
682
- HtmlParser.Attribute = 'Attribute';
683
- HtmlParser.Text = 'Text';
684
- HtmlParser.Tag = 'Tag';
730
+ /**
731
+ * Created by h.selector(). Holds one selected key.
732
+ *
733
+ * Only attribute expressions can bind a selector; using one as element content throws,
734
+ * because writing text through this path would need bookkeeping the two-node fast case
735
+ * doesn't want.
736
+ *
737
+ * The selector keeps **no per-row state at all** — no map of keys, nothing to sweep, and
738
+ * nothing that could pin a removed row's element in memory. All it remembers is which
739
+ * attribute it drives and which list it was rendered into.
740
+ */
741
+ class Selector {
742
+
743
+ /** @type {*} The selected key, or null. */
744
+ #key = null;
745
+
746
+ /** @type {SelectorRef} Returned by when() for the row whose key is selected. */
747
+ #on = new SelectorRef(this, true);
748
+
749
+ /** @type {SelectorRef} Returned by when() for every other row. */
750
+ #off = new SelectorRef(this, false);
751
+
752
+ /** @type {*} Value the bound attribute takes for the selected key. Held here rather than
753
+ * on each ref, so the two refs stay interchangeable between call sites. */
754
+ onValue;
755
+
756
+ /** @type {*} Value it takes for every other key. */
757
+ offValue = '';
758
+
759
+ /** @type {?string} The attribute this selector drives, learned when a row binds. */
760
+ attribName = null;
761
+
762
+ /** @type {?PathToNodes} The list this selector's rows were rendered into, learned when a
763
+ * row binds. set() asks it for the NodeGroup holding a given key. */
764
+ path = null;
765
+
766
+ /** @param key {*} The initially selected key. */
767
+ constructor(key = null) {
768
+ this.#key = key;
769
+ }
770
+
771
+ /** @return {*} The selected key. */
772
+ get key() {
773
+ return this.#key;
774
+ }
775
+
776
+ /**
777
+ * Bind an attribute to whether key is the selected one.
778
+ *
779
+ * h`<tr key=${row.id} class=${sel.when(row.id, 'danger')}>`
780
+ *
781
+ * @param key {*} This row's key.
782
+ * @param on {*} Value the attribute takes when key is selected.
783
+ * @param off {*} Value it takes otherwise. '' removes the attribute.
784
+ * @return {SelectorRef} */
785
+ when(key, on, off = '') {
786
+ this.onValue = on;
787
+ this.offValue = off;
788
+ return key === this.#key ? this.#on : this.#off;
789
+ }
790
+
791
+ /**
792
+ * Move the selection. Writes at most two attributes — the row losing the selection and
793
+ * the row gaining it — and touches nothing else. There is no render() call.
794
+ * @param key {*} The newly selected key, or null for none. */
795
+ set(key) {
796
+ let old = this.#key;
797
+ if (old === key)
798
+ return;
799
+ this.#key = key;
800
+
801
+ // Nothing has rendered a row yet, so there is no list to write into. The new key
802
+ // still takes effect: rows drawn later come up already carrying the attribute.
803
+ if (this.path === null)
804
+ return;
805
+
806
+ this.#write(old, this.offValue);
807
+ this.#write(key, this.onValue);
808
+ }
809
+
810
+ /**
811
+ * Find the row holding key and give its root element the value v.
812
+ * @param key {*}
813
+ * @param v {*} */
814
+ #write(key, v) {
815
+ if (key === null || key === undefined)
816
+ return;
817
+
818
+ let ngs = this.path.nodeGroups;
819
+ if (ngs === null || ngs.length === 0)
820
+ return;
821
+
822
+ if (ngs[0].key === undefined)
823
+ throw new Error('Solarite: a selector must be on a keyed list, as key=${...}.');
824
+
825
+ // A linear scan over the rows. The list is walked only when the selection actually
826
+ // moves — twice per user click, not once per row per render — so a thousand pointer
827
+ // comparisons here cost far less than the per-row index that would avoid them.
828
+ let ng = null;
829
+ for (let i = 0; i < ngs.length; i++)
830
+ if (ngs[i].key === key) {
831
+ ng = ngs[i];
832
+ break;
833
+ }
834
+ if (ng === null)
835
+ return;
836
+
837
+ // The selector owns an attribute on the row's own root element, which for a
838
+ // single-root row template is exactly the NodeGroup's startNode.
839
+ let node = ng.startNode;
840
+ if (node === null || node.nodeType !== 1)
841
+ return;
842
+
843
+ if (v === '' || v === false || v === null || v === undefined)
844
+ node.removeAttribute(this.attribName);
845
+ else
846
+ node.setAttribute(this.attribName, v);
847
+ }
848
+ }
685
849
 
686
850
  class PathToAttribValue extends Path {
687
851
 
688
852
  /** @type {?string} Used only if type=AttribType.Value. */
689
- attrName;
853
+ attribName;
690
854
 
691
855
  /**
692
856
  * @type {?string[]} Used only if type=AttribType.Value. If null, use one expr to set the whole attribute value. */
693
857
  attrValue;
694
858
 
695
- /** @type {boolean} Provides value for attribute on a component. */
696
- isComponent;
859
+ // isComponentAttrib and isHtmlProperty are declared on the Path base class.
697
860
 
698
- isHtmlProperty;
699
-
700
- constructor(nodeBefore, nodeMarker, attrName=null, attrValue=null) {
861
+ constructor(nodeBefore, nodeMarker, attribName=null, attrValue=null) {
701
862
  super(null, nodeMarker);
702
- this.attrName = attrName;
863
+ this.attribName = attribName;
703
864
  this.attrValue = attrValue;
704
865
  }
705
866
 
706
867
  /**
707
868
  * Set the value of an attribute. This can be for any attribute, not just attributes named "value".
708
869
  * @param exprs {Expr[]} */
709
- apply(exprs) {
870
+ applyAll(exprs) {
710
871
 
711
872
 
712
873
  // Multiple expressions in one attribute value, e.g. class="a ${b} c ${d}"
@@ -718,14 +879,14 @@ class PathToAttribValue extends Path {
718
879
  // Only update attributes if the value has changed.
719
880
  // This is needed for setting input.value, .checked, option.selected, etc.
720
881
  let oldVal = isProp
721
- ? node[this.attrName]
722
- : node.getAttribute(this.attrName);
882
+ ? node[this.attribName]
883
+ : node.getAttribute(this.attribName);
723
884
  if (oldVal !== joinedValue) {
724
885
  if (isProp)
725
- node[this.attrName] = joinedValue;
726
- else if (this.attrName === 'value' && node.hasAttribute('contenteditable'))
886
+ node[this.attribName] = joinedValue;
887
+ else if (this.attribName === 'value' && node.hasAttribute('contenteditable'))
727
888
  node.innerHTML = joinedValue;
728
- node.setAttribute(this.attrName, joinedValue);
889
+ node.setAttribute(this.attribName, joinedValue);
729
890
  }
730
891
  }
731
892
  else
@@ -738,7 +899,7 @@ class PathToAttribValue extends Path {
738
899
  applySingle(expr) {
739
900
  // One expression surrounded by strings, e.g. class="a ${b} c". Join through apply().
740
901
  if (this.attrValue)
741
- return this.apply([expr]);
902
+ return this.applyAll([expr]);
742
903
 
743
904
  let node = this.nodeMarker;
744
905
 
@@ -759,12 +920,12 @@ class PathToAttribValue extends Path {
759
920
  let [obj, path] = [expr[0], expr.slice(1)];
760
921
 
761
922
  if (!obj)
762
- throw new Error(`Solarite cannot bind to <${node.tagName.toLowerCase()} ${this.attrName}=\${[${expr.map(item => item ? `'${item}'` : item+'').join(', ')}]}>.`);
923
+ throw new Error(`Solarite cannot bind ${this.attribName} to ${obj}.`);
763
924
 
764
925
  let value = delve(obj, path);
765
926
 
766
927
  // Special case to allow setting select-multiple value from an array
767
- if (this.attrName === 'value' && node.type === 'select-multiple' && Array.isArray(value)) {
928
+ if (this.attribName === 'value' && node.type === 'select-multiple' && Array.isArray(value)) {
768
929
  // Set the .selected property on the options having a value within value.
769
930
  let strValues = value.map(v => v + '');
770
931
  for (let option of node.options)
@@ -780,7 +941,7 @@ class PathToAttribValue extends Path {
780
941
  const strValue = Util.isFalsy(value) ? '' : value;
781
942
 
782
943
  // Special case for contenteditable
783
- if (this.attrName === 'value' && node.hasAttribute('contenteditable')) {
944
+ if (this.attribName === 'value' && node.hasAttribute('contenteditable')) {
784
945
  const existingValue = node.innerHTML;
785
946
  if (strValue !== existingValue)
786
947
  node.innerHTML = strValue;
@@ -789,28 +950,39 @@ class PathToAttribValue extends Path {
789
950
 
790
951
  // If we don't have this condition, when we call render(), the browser will scroll to the currently
791
952
  // selected item in a <select> and mess up manually scrolling to a different value.
792
- if (strValue !== node[this.attrName])
793
- node[this.attrName] = strValue;
953
+ if (strValue !== node[this.attribName])
954
+ node[this.attribName] = strValue;
794
955
  }
795
956
  }
796
957
 
797
958
  // TODO: We need to remove any old listeners, like in bindEventAttribute.
798
959
  // Does bindEvent() now handle that?
799
960
  let func = () => {
800
- let value = (this.attrName === 'value' || node.type === 'radio')
961
+ let value = (this.attribName === 'value' || node.type === 'radio')
801
962
  ? Util.getInputValue(node)
802
- : node[this.attrName];
963
+ : node[this.attribName];
803
964
  delve(obj, path, value);
804
965
  };
805
966
 
806
967
  // We use capture so we update the values before other events added by the user.
807
968
  // TODO: Bind to scroll events also?
808
969
  // What about resize events and width/height?
809
- this.bindEvent(node, this.parentNg.getRootNode(), this.attrName, 'input', func, null, true);
970
+ this.bindEvent(node, this.parentNg.getRootEl(), this.attribName, 'input', func, null, true);
810
971
  }
811
972
 
812
973
  // Regular attribute
813
974
  else {
975
+ // A selection binding (h.selector().when()) writes its own value and tells the
976
+ // selector which list this row belongs to, so a later change of selection reaches
977
+ // the attribute directly instead of going back through render(). The typeof test
978
+ // keeps ordinary string attributes — nearly all of them — from paying for the
979
+ // prototype check.
980
+ if (typeof expr === 'object' && expr instanceof SelectorRef) {
981
+ if (!this.isComponentAttrib)
982
+ expr.bind(node, this.attribName, this.parentNg);
983
+ return;
984
+ }
985
+
814
986
  // Cache this on Path.isHtmlProperty when Shell creates the props.
815
987
  // Have Path.clone() copy .isHtmlProperty?
816
988
  let isProp = this.isHtmlProperty;
@@ -823,43 +995,53 @@ class PathToAttribValue extends Path {
823
995
  else
824
996
  expr = Util.makePrimitive(expr);
825
997
 
826
- // Values to toggle an attribute
827
- if (expr === undefined || expr === false || expr === null) { // Util.isFalsy() inlined.
828
- if (isProp)
829
- node[this.attrName] = false;
830
- node.removeAttribute(this.attrName);
998
+ // Values that remove an attribute. The empty string is included so that an attribute
999
+ // disappears whenever its expression is empty, instead of only when it happened to be
1000
+ // absent already. makePrimitive() above turns null into '', so plain null lands here
1001
+ // too; the explicit null test still matters for a function expression returning null,
1002
+ // which skips makePrimitive.
1003
+ // An html property is exempt: on those, '' is a real value meaning "empty", as when
1004
+ // clearing an <input>, so it belongs on the assignment path below.
1005
+ if (expr === undefined || expr === false || expr === null || (expr === '' && !isProp)) {
1006
+ if (isProp) {
1007
+ // Clear the property with a value of its own type. Assigning false to a string
1008
+ // property such as input.value would put the text "false" in the field.
1009
+ let old = node[this.attribName];
1010
+ node[this.attribName] = typeof old === 'boolean' ? false : '';
1011
+ }
1012
+ node.removeAttribute(this.attribName);
831
1013
  }
832
1014
  else if (expr === true) {
833
1015
  if (isProp)
834
- node[this.attrName] = true;
835
- node.setAttribute(this.attrName, '');
1016
+ node[this.attribName] = true;
1017
+ node.setAttribute(this.attribName, '');
836
1018
  }
837
1019
 
838
1020
  // A non-toggled attribute
839
1021
  else {
840
1022
  // Only update attributes if the value has changed.
841
1023
  // This is needed for setting input.value, .checked, option.selected, etc.
842
- // A missing attribute counts as '', so empty values don't write empty attributes.
1024
+ // Non-property attributes never reach here with '', since that removes above.
843
1025
  let oldVal = isProp
844
- ? node[this.attrName]
845
- : node.getAttribute(this.attrName) ?? '';
1026
+ ? node[this.attribName]
1027
+ : node.getAttribute(this.attribName) ?? '';
846
1028
  if (oldVal !== expr) {
847
1029
 
848
1030
  // <textarea value=${expr}></textarea>
849
1031
  // Without this branch we have no way to set the value of a textarea,
850
1032
  // since we also prohibit expressions that are a child of textarea.
851
1033
  if (isProp)
852
- node[this.attrName] = expr;
1034
+ node[this.attribName] = expr;
853
1035
 
854
1036
  // Allow one-way binding to contenteditable value attribute.
855
1037
  // Contenteditables normally don't have a value attribute and have their content set via innerHTML.
856
1038
  // Solarite doesn't allow contenteditables to have expressions as their children.
857
- else if (this.attrName === 'value' && node.hasAttribute('contenteditable')) {
1039
+ else if (this.attribName === 'value' && node.hasAttribute('contenteditable')) {
858
1040
  node.innerHTML = expr;
859
1041
  }
860
1042
 
861
1043
  // TODO: Putting an 'else' here would be more performant
862
- node.setAttribute(this.attrName, expr);
1044
+ node.setAttribute(this.attribName, expr);
863
1045
  }
864
1046
  }
865
1047
  }
@@ -887,6 +1069,18 @@ class PathToAttribValue extends Path {
887
1069
  for (let i = 0; i < values.length; i++) {
888
1070
  result.push(values[i]);
889
1071
  if (i < values.length - 1) {
1072
+ // A selection binding has to own the whole attribute, because its whole point is
1073
+ // writing that attribute without re-rendering, which it can't do if the rest of
1074
+ // the value comes from expressions it doesn't know about. Whether a selector sits
1075
+ // inside a multi-part attribute is fixed by the shape of the template and never by
1076
+ // the data, so this can only be an authoring mistake, and it always surfaces on the
1077
+ // template's very first render -- exactly like the placement check in
1078
+ // SelectorRef.bind(). That makes it safe to strip from the built file, where the
1079
+ // throw is the only thing lost: makePrimitive() then turns the ref into '' and the
1080
+ // attribute is written from its constant parts alone. Stripping it also keeps a
1081
+ // per-expression instanceof out of the multi-part attribute loop.
1082
+ if (typeof exprs[i] === 'object' && exprs[i] instanceof SelectorRef)
1083
+ throw new Error(`Solarite: a selector must own the whole ${this.attribName} attribute.`);
890
1084
  let val = Util.makePrimitive(exprs[i]);
891
1085
  if (!Util.isFalsy(val))
892
1086
  result.push(val);
@@ -907,17 +1101,35 @@ class PathToAttribValue extends Path {
907
1101
  /**
908
1102
  * @param funcAndArgs {?Array} The [func, ...args] array from the template, or null if func stands alone. */
909
1103
  bindEvent(node, root, key, eventName, func, funcAndArgs, capture=false) {
910
- if (typeof func !== 'function')
911
- throw new Error(`Solarite cannot bind to <${node.tagName.toLowerCase()} ${this.attrName}=\${${func}}> because it's not a function.`);
1104
+
912
1105
 
913
- // Whether to delegate is decided in registerBinding(), which only runs for a NEW binding.
914
- // Re-renders rebind existing rows (just updating binding.args below), so they skip the
915
- // options lookup + delegatableEvents check entirely.
916
- let options = this.parentNg.rootNg.options;
1106
+ // Delegated path: a bubbling event (when the root's options allow it, the default)
1107
+ // stores its handler directly on the node as a per-event-type Symbol expando, with no
1108
+ // EventBinding object and no addEventListener call. When an event of that type
1109
+ // starts, jitDispatcher() attaches a real listener to each node on its path that
1110
+ // carries the expando, so the browser runs the handler at the node's own turn.
1111
+ // Re-renders just overwrite the property. this.delegatedKey is set by the PathToEvent
1112
+ // constructor only for delegatable event names, so this test also excludes
1113
+ // non-bubbling events and native:on* bindings.
1114
+ if (capture === false && this.delegatedKey !== undefined) {
1115
+ let opt = this.parentNg.rootNg.renderOptions?.eventDelegation ?? true;
1116
+ // true delegates everything, an array only the events it names, and any other
1117
+ // value (such as the retired 'document' string) counts as true.
1118
+ if (opt !== false && (!Array.isArray(opt) || opt.includes(eventName))) {
1119
+ let dk = this.delegatedKey;
1120
+ if (node[dk] === undefined) // First binding of this type on this node.
1121
+ ensureDelegatedDispatcher(root, eventName);
1122
+ // Array-form bindings (onclick=${[fn, arg]}, the hot per-row case) store the
1123
+ // template's own [func, ...args] array; a plain function is stored bare.
1124
+ // Either way, nothing is allocated.
1125
+ node[dk] = funcAndArgs || func;
1126
+ node[delegatedRootKey] = root;
1127
+ return;
1128
+ }
1129
+ }
917
1130
 
918
- // Store the callable as a single [func, ...args] array. Array-form bindings
919
- // (onclick=${[fn, arg]}, the hot per-row case) pass it through with no allocation;
920
- // a plain function allocates a one-element array, which is rare (buttons, two-way).
1131
+ // Direct path: capture bindings, non-bubbling events, and eventDelegation:false.
1132
+ // Store the callable as a single [func, ...args] array.
921
1133
  let args = funcAndArgs || [func];
922
1134
 
923
1135
  // One stable EventBinding object per node+key is registered with addEventListener
@@ -927,7 +1139,7 @@ class PathToAttribValue extends Path {
927
1139
  let nodeEvents = node[eventBindingsKey];
928
1140
  if (nodeEvents === undefined) {
929
1141
  let b = node[eventBindingsKey] = new EventBinding(root, node, key, args);
930
- registerBinding(b, node, eventName, capture, options, root);
1142
+ node.addEventListener(eventName, b, capture);
931
1143
  return;
932
1144
  }
933
1145
 
@@ -945,7 +1157,7 @@ class PathToAttribValue extends Path {
945
1157
  let map = node[eventBindingsKey] = {};
946
1158
  map[nodeEvents.key] = nodeEvents;
947
1159
  binding = map[key] = new EventBinding(root, node, key, args);
948
- registerBinding(binding, node, eventName, capture, options, root);
1160
+ node.addEventListener(eventName, binding, capture);
949
1161
  return;
950
1162
  }
951
1163
  }
@@ -953,11 +1165,11 @@ class PathToAttribValue extends Path {
953
1165
  binding = nodeEvents[key];
954
1166
  if (!binding) {
955
1167
  binding = nodeEvents[key] = new EventBinding(root, node, key, args);
956
- registerBinding(binding, node, eventName, capture, options, root);
1168
+ node.addEventListener(eventName, binding, capture);
957
1169
  return;
958
1170
  }
959
1171
  }
960
- binding.root = root;
1172
+ binding.rootEl = root;
961
1173
  binding.args = args;
962
1174
  }
963
1175
  }
@@ -978,82 +1190,171 @@ function getEventBinding(node, key) {
978
1190
  return b instanceof EventBinding ? (b.key === key ? b : undefined) : b[key];
979
1191
  }
980
1192
 
1193
+ // Bubbling events the just-in-time dispatcher handles. Same set Solid.js delegates.
1194
+ const delegatableEvents = new Set(['beforeinput', 'click', 'contextmenu', 'dblclick', 'focusin', 'focusout',
1195
+ 'input', 'keydown', 'keyup', 'mousedown', 'mousemove', 'mouseout', 'mouseover', 'mouseup',
1196
+ 'pointerdown', 'pointermove', 'pointerout', 'pointerover', 'pointerup', 'touchend', 'touchmove', 'touchstart']);
1197
+
1198
+ // One Symbol per delegated event type; nodes store their delegated handler under it.
1199
+ // Symbols (vs string expandos like Solid's $$click) can't collide with user properties.
1200
+ const delegatedKeys = {};
1201
+
981
1202
  /**
982
- * Attach a new EventBinding either directly or through the root component's delegated
983
- * dispatcher. The dispatcher lives on the root element (not the document) so a component
984
- * still receives delegated events while detached from the document, and events stay scoped
985
- * to the component that rendered them. */
986
- function registerBinding(binding, node, eventName, capture, options, root) {
987
- // Bubbling events are delegated by default: they skip addEventListener entirely, and one
988
- // root-level dispatcher per event type finds bindings by walking up from the event target.
989
- // eventDelegation:false opts out; an array delegates only the named events. Capture
990
- // bindings and non-bubbling events always stay direct.
991
- let delegate = false;
992
- if (capture === false) {
993
- let opt = options?.eventDelegation ?? true;
994
- if (opt !== false && delegatableEvents.has(eventName))
995
- delegate = opt === true || opt.includes(eventName);
996
- }
1203
+ * Get the per-event-type Symbol key, or undefined for non-delegatable events.
1204
+ * Called once per PathToEvent construction, never per bind.
1205
+ * @param eventName {string}
1206
+ * @return {symbol|undefined} */
1207
+ function delegatedKeyFor(eventName) {
1208
+ if (!delegatableEvents.has(eventName))
1209
+ return undefined;
1210
+ return delegatedKeys[eventName] ??= Symbol('sol$' + eventName);
1211
+ }
1212
+
1213
+ // The component root a node's delegated handlers run with as `this`.
1214
+ // Exported so NodeGroup.applyStamp()'s compiled stamp program can write it directly.
1215
+ const delegatedRootKey = Symbol('solariteDelegatedRoot');
1216
+
1217
+ // Set of event types that already have the dispatcher registered, kept on each root element
1218
+ // and on each document.
1219
+ const delegatedTypesKey = Symbol('solariteDelegatedTypes');
997
1220
 
998
- if (delegate) {
999
- binding.delegated = true;
1000
- let types = root[delegatedTypesKey];
1001
- if (types === undefined)
1002
- types = root[delegatedTypesKey] = new Set();
1003
- if (!types.has(eventName)) {
1004
- types.add(eventName);
1005
- root.addEventListener(eventName, delegatedDispatcher);
1221
+ /**
1222
+ * Register the just-in-time dispatcher for eventName on root and on root's document, once
1223
+ * each. Shared by bindEvent()'s delegated branch and NodeGroup.applyStamp()'s stamp program.
1224
+ *
1225
+ * Both registrations are needed. The document's listener is what still reaches a bound node
1226
+ * after another component re-parents it outside its root (a toolbar a dock parks in its own
1227
+ * chrome). The root's listener is what reaches what the document cannot see: a component
1228
+ * that isn't in the document at all, nodes inside a closed shadow root, and a synthetic
1229
+ * event dispatched inside any shadow root without composed:true, which never leaves it.
1230
+ * @param root {HTMLElement}
1231
+ * @param eventName {string} */
1232
+ function ensureDelegatedDispatcher(root, eventName) {
1233
+ let types = root[delegatedTypesKey];
1234
+ if (types === undefined)
1235
+ types = root[delegatedTypesKey] = new Set();
1236
+ if (!types.has(eventName)) {
1237
+ types.add(eventName);
1238
+ root.addEventListener(eventName, jitDispatcher, true);
1239
+
1240
+ let doc = root.ownerDocument;
1241
+ let docTypes = doc[delegatedTypesKey];
1242
+ if (docTypes === undefined)
1243
+ docTypes = doc[delegatedTypesKey] = new Set();
1244
+ if (!docTypes.has(eventName)) {
1245
+ docTypes.add(eventName);
1246
+ doc.addEventListener(eventName, jitDispatcher, true);
1006
1247
  }
1007
1248
  }
1008
- else
1009
- node.addEventListener(eventName, binding, capture);
1010
1249
  }
1011
1250
 
1012
- // Bubbling events that one root-level listener can dispatch. Same set Solid.js delegates.
1013
- const delegatableEvents = new Set(['beforeinput', 'click', 'contextmenu', 'dblclick', 'focusin', 'focusout',
1014
- 'input', 'keydown', 'keyup', 'mousedown', 'mousemove', 'mouseout', 'mouseover', 'mouseup',
1015
- 'pointerdown', 'pointermove', 'pointerout', 'pointerover', 'pointerup', 'touchend', 'touchmove', 'touchstart']);
1251
+ // Set on an event by the first dispatcher to walk it, holding the length of the path it saw,
1252
+ // so the dispatchers on nested roots further down don't repeat the walk. A root inside a
1253
+ // closed shadow root sees a longer path than the document did, because composedPath() hides
1254
+ // a closed tree from listeners outside it, and that mismatch is what makes it walk again.
1255
+ const delegatedDoneKey = Symbol('solariteDelegated');
1016
1256
 
1017
- // Per-root-element Set of event types that already have a delegated dispatcher registered.
1018
- const delegatedTypesKey = Symbol('solariteDelegatedTypes');
1257
+ /**
1258
+ * One shared bubble-phase listener per event type, attached to a node only for the duration
1259
+ * of one event. The browser invokes it at the node's own turn in propagation, and it reads
1260
+ * the node's handler THEN rather than when it was attached, so a handler that an earlier
1261
+ * listener in the same dispatch replaced or removed is honored.
1262
+ * @type {Object<string, {handleEvent: function(Event)}>} */
1263
+ const trampolines = {};
1019
1264
 
1020
- // Marks an event the innermost root dispatcher has already walked, so an outer root's
1021
- // listener (when components are nested) skips it instead of dispatching the bindings again.
1022
- const delegatedDoneKey = Symbol('solariteDelegated');
1265
+ /**
1266
+ * @param type {string}
1267
+ * @return {{handleEvent: function(Event)}} */
1268
+ function trampolineFor(type) {
1269
+ let tramp = trampolines[type];
1270
+ if (tramp === undefined) {
1271
+ let dk = delegatedKeys[type];
1272
+ tramp = trampolines[type] = {
1273
+ // Quoted so the minifier's property mangling doesn't rename it, since the browser looks it up by name.
1274
+ 'handleEvent'(ev) {
1275
+ let node = ev.currentTarget;
1276
+ let a = node[dk];
1277
+ if (a === undefined) // Unbound by an earlier handler in this same dispatch.
1278
+ return;
1279
+ let root = node[delegatedRootKey];
1280
+ if (typeof a === 'function')
1281
+ a.call(root, ev, node);
1282
+ else
1283
+ switch (a.length) {
1284
+ case 1: a[0].call(root, ev, node); break;
1285
+ case 2: a[0].call(root, a[1], ev, node); break;
1286
+ case 3: a[0].call(root, a[1], a[2], ev, node); break;
1287
+ default: a[0].call(root, ...a.slice(1), ev, node);
1288
+ }
1289
+ }
1290
+ };
1291
+ }
1292
+ return tramp;
1293
+ }
1294
+
1295
+ // Nodes still carrying a trampoline, per event type, and the one timer that clears them.
1296
+ const pending = {};
1297
+ let sweepTimer = 0;
1023
1298
 
1024
1299
  /**
1025
- * The per-root listener for each delegated event type. The first (innermost) root the
1026
- * bubbling event reaches walks from the event target upward, invoking delegated
1027
- * EventBindings stored on the nodes along the way; outer roots then see the done-marker and
1028
- * skip. Each binding carries its own root, so handlers in an outer component still run with
1029
- * the correct `this`. event.currentTarget is patched to the node whose binding is running,
1030
- * and restored after. stopPropagation() inside a handler ends the walk, mirroring native
1031
- * bubbling. */
1032
- function delegatedDispatcher(ev) {
1033
- if (ev[delegatedDoneKey])
1300
+ * Remove every trampoline attached since the last sweep. Runs as a task, which is always
1301
+ * after every dispatch in progress has finished. A microtask would not be: for a real click
1302
+ * the browser runs a microtask checkpoint between listeners, so a microtask sweep would strip
1303
+ * the trampolines before the event reached the first of them. The sweep is housekeeping
1304
+ * only; a trampoline left in place is harmless, because jitDispatcher() re-attaches it and
1305
+ * the trampoline reads its handler fresh. */
1306
+ function sweep() {
1307
+ sweepTimer = 0;
1308
+ for (let type in pending) {
1309
+ let nodes = pending[type];
1310
+ if (nodes.length !== 0) {
1311
+ pending[type] = [];
1312
+ let tramp = trampolines[type];
1313
+ for (let i=0; i<nodes.length; i++)
1314
+ nodes[i].removeEventListener(type, tramp);
1315
+ }
1316
+ }
1317
+ }
1318
+
1319
+ /**
1320
+ * The capture-phase listener registered per delegated event type on every root and on the
1321
+ * document. It runs before the event reaches anything, walks the event's path, and attaches
1322
+ * the type's trampoline to each node holding a delegated handler. The browser then finishes
1323
+ * the dispatch natively, so those handlers interleave correctly with listeners anyone else
1324
+ * registered, stopPropagation() works in both directions, currentTarget is right, and the
1325
+ * event needn't bubble.
1326
+ *
1327
+ * Each attach removes the trampoline first. One left from an earlier event in this same task
1328
+ * would otherwise keep its old place in the node's listener list, ahead of listeners added
1329
+ * since; removing and re-adding puts it last, so the rule holds without exception: a
1330
+ * delegated handler runs after every listener its element had when the event started. */
1331
+ function jitDispatcher(ev) {
1332
+ let path = ev.composedPath();
1333
+ if (ev[delegatedDoneKey] === path.length)
1034
1334
  return;
1035
- ev[delegatedDoneKey] = true;
1335
+ ev[delegatedDoneKey] = path.length;
1336
+
1036
1337
  let type = ev.type;
1037
- let current = ev.target;
1038
- Object.defineProperty(ev, 'currentTarget', {configurable: true, get() { return current }});
1039
- while (current) {
1040
- let b = current[eventBindingsKey];
1041
- if (b !== undefined) {
1042
- let binding = b instanceof EventBinding ? b : b[type];
1043
- if (binding !== undefined && binding.delegated === true && binding.key === type) {
1044
- binding.handleEvent(ev);
1045
- if (ev.cancelBubble)
1046
- break;
1047
- }
1338
+ let dk = delegatedKeys[type];
1339
+ let tramp = trampolineFor(type);
1340
+ let list = pending[type];
1341
+ if (list === undefined)
1342
+ list = pending[type] = [];
1343
+ for (let i=0; i<path.length; i++) {
1344
+ let node = path[i];
1345
+ if (node[dk] !== undefined) {
1346
+ node.removeEventListener(type, tramp);
1347
+ node.addEventListener(type, tramp);
1348
+ list.push(node);
1048
1349
  }
1049
- current = current.parentNode;
1050
1350
  }
1051
- delete ev.currentTarget; // Restore the native getter from the prototype.
1351
+ if (list.length !== 0 && sweepTimer === 0)
1352
+ sweepTimer = setTimeout(sweep);
1052
1353
  }
1053
1354
 
1054
1355
  class EventBinding {
1055
1356
  constructor(root, node, key, args) {
1056
- this.root = root;
1357
+ this.rootEl = root;
1057
1358
  this.node = node;
1058
1359
  this.key = key;
1059
1360
 
@@ -1067,23 +1368,42 @@ class EventBinding {
1067
1368
  'handleEvent'(event) {
1068
1369
  let a = this.args;
1069
1370
  switch (a.length) {
1070
- case 1: return a[0].call(this.root, event, this.node);
1071
- case 2: return a[0].call(this.root, a[1], event, this.node);
1072
- case 3: return a[0].call(this.root, a[1], a[2], event, this.node);
1371
+ case 1: return a[0].call(this.rootEl, event, this.node);
1372
+ case 2: return a[0].call(this.rootEl, a[1], event, this.node);
1373
+ case 3: return a[0].call(this.rootEl, a[1], a[2], event, this.node);
1073
1374
  }
1074
- return a[0].call(this.root, ...a.slice(1), event, this.node);
1375
+ return a[0].call(this.rootEl, ...a.slice(1), event, this.node);
1075
1376
  }
1076
1377
  }
1077
1378
 
1078
1379
  // TODO: Merge this into PathToAttribValue?
1079
1380
  class PathToEvent extends PathToAttribValue {
1080
1381
 
1081
- /** @type {string} The attrName without the "on" prefix. */
1382
+ /** @type {string} The attribName without the "on" prefix. */
1082
1383
  eventName;
1083
1384
 
1084
- constructor(nodeBefore, nodeMarker, attrName=null, attrValue=null) {
1085
- super(null, nodeMarker, attrName, attrValue);
1086
- this.eventName = attrName ? attrName.slice(2) : null;
1385
+ /** @type {symbol|undefined} Expando key nodes store this event's delegated handler under.
1386
+ * Undefined for non-delegatable (non-bubbling) events; bindEvent() then binds directly. */
1387
+ delegatedKey;
1388
+
1389
+ /** @type {boolean} True for `native:onclick`: the handler is registered with addEventListener
1390
+ * when the template renders, so it runs at its element's own turn in the browser's dispatch
1391
+ * order instead of being delegated to the component root. */
1392
+ native;
1393
+
1394
+ constructor(nodeBefore, nodeMarker, attribName=null, attrValue=null) {
1395
+ super(null, nodeMarker, attribName, attrValue);
1396
+ this.skipIfSame = true;
1397
+ let name = attribName;
1398
+ this.native = name !== null && name.startsWith(nativeEventPrefix);
1399
+ if (this.native)
1400
+ name = name.slice(nativeEventPrefix.length);
1401
+ this.eventName = name ? name.slice(2) : null;
1402
+
1403
+ // A native binding leaves delegatedKey undefined. That is the single switch both
1404
+ // bindEvent() and the compiled stamp program test to choose the direct
1405
+ // addEventListener path, so nothing else has to know about the prefix.
1406
+ this.delegatedKey = (this.eventName !== null && !this.native) ? delegatedKeyFor(this.eventName) : undefined;
1087
1407
  }
1088
1408
 
1089
1409
  /**
@@ -1093,14 +1413,14 @@ class PathToEvent extends PathToAttribValue {
1093
1413
  * onclick=${[this, 'doSomething', 'meow']}
1094
1414
  *
1095
1415
  * @param exprs {Expr[]} Only the first is used.*/
1096
- apply(exprs) {
1416
+ applyAll(exprs) {
1097
1417
 
1098
1418
 
1099
1419
  // Tested by Solariate.events.classicWithExpr
1100
1420
  // We have expressions within a string attribute value that's not a Solarite event. E.g.
1101
1421
  // <div onclick="alert(${1});"
1102
1422
  if (this.attrValue?.length > 1) {
1103
- super.apply(exprs);
1423
+ super.applyAll(exprs);
1104
1424
  return;
1105
1425
  }
1106
1426
 
@@ -1112,14 +1432,14 @@ class PathToEvent extends PathToAttribValue {
1112
1432
  applySingle(expr) {
1113
1433
  // Expressions within a string attribute value that's not a Solarite event.
1114
1434
  if (this.attrValue?.length > 1)
1115
- return super.apply([expr]);
1435
+ return super.applyAll([expr]);
1116
1436
 
1117
1437
  // Don't bind events to component placeholders.
1118
1438
  // PathToComponent will do the binding later when it instantiates the component.
1119
1439
  if (this.isComponentAttrib && this.nodeMarker.tagName.endsWith('-SOLARITE-PLACEHOLDER'))
1120
1440
  return;
1121
1441
 
1122
- let root = this.parentNg.rootNg.root;
1442
+ let root = this.parentNg.rootNg.rootEl;
1123
1443
 
1124
1444
 
1125
1445
 
@@ -1137,7 +1457,7 @@ class PathToEvent extends PathToAttribValue {
1137
1457
  expr = null;
1138
1458
  }
1139
1459
  else
1140
- throw new Error(`Invalid event binding: <${node.tagName.toLowerCase()} ${this.attrName}=\${${JSON.stringify(expr)}}>`);
1460
+ throw new Error(`Solarite: ${this.attribName}=\${...} is not a function.`);
1141
1461
 
1142
1462
  this.bindEvent(node, root, eventName, eventName, func, expr);
1143
1463
  }
@@ -1260,13 +1580,10 @@ function jsxToTemplate(tag, props, children=[], key=undefined) {
1260
1580
 
1261
1581
  // 2a. Custom element class => emit <tag-name ...props>children</tag-name>; PathToComponent
1262
1582
  // instantiates it exactly like a tagged-template component.
1263
- if (tag.prototype instanceof HTMLElement) {
1264
- Util.defineClass(tag);
1265
- let tagName = customElements.getName ? customElements.getName(tag) : Util.camelToDashes(tag.name);
1266
- if (tagName && !tagName.includes('-'))
1267
- tagName += '-element';
1268
- return buildIntrinsic(tagName, props, children, key);
1269
- }
1583
+ // defineClass() hands back the name it registered, or the name the class was already
1584
+ // registered under, so we never have to guess it a second time.
1585
+ if (tag.prototype instanceof HTMLElement)
1586
+ return buildIntrinsic(Util.defineClass(tag), props, children, key);
1270
1587
 
1271
1588
  // 2b. Plain function component: call it with props (+ children) and expect a Template back.
1272
1589
  let p = {};
@@ -1344,22 +1661,21 @@ class PathToAttribs extends Path {
1344
1661
  * @type {Set<string>} Used for type=AttribType.Multiple to remember the attributes that were added. */
1345
1662
  attrNames;
1346
1663
 
1347
- /** @type {boolean} Provides one or more attributes on a component. */
1348
- isComponent;
1664
+ /** @type {PathToEvent|PathToAttribValue|undefined} Cached sub-path for the JSX
1665
+ * whole-attribute fast path; see applyJsxAttr(). Declared so the first assignment
1666
+ * doesn't transition the hidden class. */
1667
+ jsxSub;
1668
+
1669
+ /** @type {?string} The attribute name jsxSub was built for. */
1670
+ jsxSubName;
1349
1671
 
1350
1672
  constructor(nodeBefore, nodeMarker) {
1351
- super(null, null);
1352
- this.nodeMarker = nodeMarker;
1673
+ // nodeBefore is discarded: an attribute path has no nodes of its own. The marker goes
1674
+ // straight through the base constructor rather than being stored a second time after it.
1675
+ super(null, nodeMarker);
1353
1676
  this.attrNames = new Set();
1354
1677
  }
1355
1678
 
1356
- /**
1357
- * @param exprs {Expr[][]} Only the first is used. */
1358
- apply(exprs) {
1359
-
1360
- this.applySingle(exprs[0]);
1361
- }
1362
-
1363
1679
  /**
1364
1680
  * @param expr {Expr} */
1365
1681
  applySingle(expr) {
@@ -1438,17 +1754,13 @@ class PathToAttribs extends Path {
1438
1754
  value = styleToCss(value);
1439
1755
  sub.applySingle(value);
1440
1756
  }
1441
-
1442
-
1443
- getExpressionCount() { return 1 }
1444
- getValue(exprs) { return exprs[0]; }
1445
1757
  }
1446
1758
 
1447
1759
  /**
1448
1760
  * Maps a string key to multiple values.
1449
1761
  * Values are stored in arrays because pushing them is much faster than Set operations,
1450
1762
  * and deleteAny() needs no iterator allocation.
1451
- * deleteAny() returns values first-in-first-out by advancing a head index (array.head)
1763
+ * deleteAny() returns values first-in-first-out by advancing a head index (array.hd)
1452
1764
  * instead of calling shift(), which would be O(n). */
1453
1765
  class MultiValueMap {
1454
1766
 
@@ -1476,7 +1788,7 @@ class MultiValueMap {
1476
1788
  let array = data[key];
1477
1789
  if (!array)
1478
1790
  data[key] = [value];
1479
- else if (array.length - (array.head || 0) < max)
1791
+ else if (array.length - (array.hd || 0) < max)
1480
1792
  array.push(value);
1481
1793
  }
1482
1794
 
@@ -1490,20 +1802,75 @@ class MultiValueMap {
1490
1802
  if (!array) // slower than pre-check.
1491
1803
  return undefined;
1492
1804
 
1493
- let head = array.head || 0;
1805
+ let head = array.hd || 0;
1494
1806
  let result = array[head];
1495
1807
  head++;
1496
1808
  if (head >= array.length)
1497
1809
  delete data[key];
1498
1810
  else
1499
- array.head = head;
1811
+ array.hd = head;
1500
1812
 
1501
1813
  return result;
1502
1814
  }
1503
1815
  }
1504
1816
 
1817
+ /**
1818
+ * A list of items plus the function that builds one item's Template, as returned by h.map().
1819
+ *
1820
+ * Handing the reconciler the source items instead of an array of Templates is what makes
1821
+ * h.map() cheap on a long list: a row whose item is the same object it was built from needs
1822
+ * neither a Template built for it nor a cache lookup to find one, just an identity check
1823
+ * against the item the row already remembers. Rows that moved are recognized too — see
1824
+ * PathToNodes.applyMapped(), which follows a shifted list's offset and, failing that, matches
1825
+ * items against the Templates the previous render built.
1826
+ */
1827
+ class MappedList {
1828
+
1829
+ /** @type {Array} */
1830
+ items;
1831
+
1832
+ /** @type {function(*):Template} */
1833
+ fn;
1834
+
1835
+ constructor(items, fn) {
1836
+ this.items = items;
1837
+ this.fn = fn;
1838
+ }
1839
+
1840
+ /**
1841
+ * Yield the Templates, building each one as it goes, so that code written against the older
1842
+ * array-returning h.map() — spreading it, iterating it, passing it to Array.from — still
1843
+ * works. Doing so builds every row, which is exactly the work the reconciler skips when the
1844
+ * list is handed to it whole, so prefer putting an h.map() straight into a template. */
1845
+ *[Symbol.iterator]() {
1846
+ let items = this.items, fn = this.fn;
1847
+ for (let i=0; i<items.length; i++)
1848
+ yield fn(items[i]);
1849
+ }
1850
+ }
1851
+
1505
1852
  class PathToNodes extends Path {
1506
1853
 
1854
+ /** @type {boolean} True once any NodeGroup this path created needs a visit even when its
1855
+ * values are unchanged (it holds a component or a live HTML property). Those rows are the
1856
+ * reason the list scans exist, so their presence rules out applyMisses()' skip-the-scan
1857
+ * path. Sticky: it's never cleared, which can only cost a scan that wasn't needed. */
1858
+ anyNeedsRefresh = false;
1859
+
1860
+ /** @type {?Array} The h.map() items the previous render drew, one per NodeGroup and in the
1861
+ * same order, so an unchanged row is recognized by comparing two arrays rather than by
1862
+ * following a pointer into each NodeGroup. A thousand rows' NodeGroups are scattered over
1863
+ * a hundred kilobytes, so reading a field from each one costs a cache miss apiece; two flat
1864
+ * arrays walk in step. Null whenever the last render wasn't an h.map().
1865
+ * @type {?Array} */
1866
+ lastItems = null;
1867
+
1868
+ /** @type {boolean} True when the previous render's items contained raw DOM Nodes,
1869
+ * which routes applySingle() to the generic reconciler. Declared so the hot
1870
+ * `!this.itemsHaveNodes` check reads a real field instead of a missing property,
1871
+ * and so the first raw-Node render doesn't transition the hidden class. */
1872
+ itemsHaveNodes = false;
1873
+
1507
1874
  /** @type {?NodeGroup[]} The NodeGroups created by this path's expression, in order.
1508
1875
  * Lazily created; null when the path has only ever rendered a primitive (see textNode). */
1509
1876
  nodeGroups = null;
@@ -1517,14 +1884,6 @@ class PathToNodes extends Path {
1517
1884
 
1518
1885
 
1519
1886
 
1520
- /**
1521
- * Nodes that have been used during the current render().
1522
- * Used with getNodeGroup() and freeNodeGroups() on the generic path; the positional diff
1523
- * tracks in-use NodeGroups in this.nodeGroups instead.
1524
- * Lazily created since most paths never use it.
1525
- * @type {?NodeGroup[]} */
1526
- nodeGroupsRendered = null;
1527
-
1528
1887
  /**
1529
1888
  * Nodes that were added to the web component during the last render(), but are available to be used again.
1530
1889
  * Used with getNodeGroup() and freeNodeGroups(), keyed by close key.
@@ -1542,16 +1901,6 @@ class PathToNodes extends Path {
1542
1901
  super(nodeBefore, nodeMarker);
1543
1902
  }
1544
1903
 
1545
- /**
1546
- * Insert/replace the nodes created by a single expression.
1547
- * Called by applyExprs()
1548
- * @param exprs {Expr[]} Only the first is used.
1549
- * @return {Node[]} New Nodes created. */
1550
- apply(exprs) {
1551
-
1552
- this.applySingle(exprs[0]);
1553
- }
1554
-
1555
1904
  /**
1556
1905
  * Make the DOM between nodeBefore and nodeMarker match the value of expr.
1557
1906
  * This is the main entry point for rendering an expression's nodes, chosen from three strategies:
@@ -1639,31 +1988,452 @@ class PathToNodes extends Path {
1639
1988
  this.textNode = null;
1640
1989
  }
1641
1990
 
1642
- // 1. Flatten the expression to a list of Templates, strings and Nodes, evaluating functions along the way.
1991
+ // A selection binding only knows how to write an attribute, so catch it here rather than
1992
+ // letting it render as an empty string and leave the caller wondering where it went.
1993
+ if (expr instanceof SelectorRef)
1994
+ throw new Error('Solarite: a selector must own the whole attribute.');
1995
+
1996
+ // 1. h.map() hands over its source items and callback rather than built Templates, so a
1997
+ // row whose item is unchanged is recognized without building or looking up a Template.
1998
+ if (expr instanceof MappedList) {
1999
+ this.applyMapped(expr);
2000
+
2001
+ return;
2002
+ }
2003
+
2004
+ // Anything that isn't an h.map() leaves no items to recognize rows by next time.
2005
+ this.lastItems = null;
2006
+
2007
+ // 2. Flatten the expression to a list of Templates, strings and Nodes, evaluating functions along the way.
2008
+ // A flat array that is entirely Templates — the rows.map(...) shape that list renders
2009
+ // produce — is borrowed directly instead of copied. The borrow lasts only for the
2010
+ // rest of this synchronous call: applyDiff/applyKeyed/applyGeneric read the items and
2011
+ // retain only the NodeGroups (and each item's own Template) built from them, never the
2012
+ // items array itself, so no reference to the caller's array survives the render. Keep
2013
+ // that invariant — storing newItems on any long-lived object would pin the caller's
2014
+ // per-render array until the next render, moving its collection into a later frame.
1643
2015
  /** @type {(Template|string|Node)[]} */
1644
- let newItems = [];
1645
- let hasNodesNow = this.collectItems(expr, newItems, false);
2016
+ let newItems = null;
2017
+ let hasNodesNow = false;
2018
+ if (Array.isArray(expr)) {
2019
+ let len = expr.length, i = 0;
2020
+ while (i < len && expr[i] instanceof Template)
2021
+ i++;
2022
+ if (i === len)
2023
+ newItems = expr; // Borrowed from the caller; read-only from here on.
2024
+ }
2025
+ if (newItems === null) {
2026
+ newItems = [];
2027
+ hasNodesNow = this.collectItems(expr, newItems, false);
2028
+ }
1646
2029
 
1647
- // 2. Raw Nodes in the items (now or on the previous render) can't be diffed positionally
2030
+ // 3. Raw Nodes in the items (now or on the previous render) can't be diffed positionally
1648
2031
  // because this.nodeGroups only tracks NodeGroups. Use the generic path for those.
1649
2032
  if (hasNodesNow || this.itemsHaveNodes) {
1650
2033
  this.itemsHaveNodes = hasNodesNow;
1651
2034
  this.applyGeneric(newItems);
1652
2035
  }
1653
- else {
1654
- // Templates with a key=${} attribute diff by key so node identity follows the data.
1655
- // An empty list also routes to applyKeyed when the previous render was keyed,
1656
- // so removed keyed NodeGroups are discarded instead of pooled.
1657
- let first = newItems.length !== 0 ? newItems[0] : null;
1658
- if (first !== null
1659
- ? (typeof first !== 'string' && (first.key !== undefined || Shell.get(first.html, first.svgMode).keyIndex >= 0))
1660
- : (this.nodeGroups !== null && this.nodeGroups.length !== 0 && this.nodeGroups[0].key !== undefined))
1661
- this.applyKeyed(newItems);
2036
+ else
2037
+ this.diffItems(newItems);
2038
+
2039
+
2040
+ }
2041
+
2042
+ /**
2043
+ * Reconcile a flat list of Templates and strings against this path's NodeGroups.
2044
+ * Templates with a key=${} attribute diff by key so node identity follows the data.
2045
+ * An empty list also routes to applyKeyed when the previous render was keyed, so removed
2046
+ * keyed NodeGroups are discarded instead of pooled.
2047
+ * @param newItems {(Template|string)[]} */
2048
+ diffItems(newItems) {
2049
+ let first = newItems.length !== 0 ? newItems[0] : null;
2050
+ if (first !== null
2051
+ ? (typeof first !== 'string' && (first.key !== undefined || Shell.get(first.html, first.svgMode).keyIndex >= 0))
2052
+ : (this.nodeGroups !== null && this.nodeGroups.length !== 0 && this.nodeGroups[0].key !== undefined))
2053
+ this.applyKeyed(newItems);
2054
+ else
2055
+ this.applyDiff(newItems);
2056
+ }
2057
+
2058
+ /**
2059
+ * Render an h.map() list.
2060
+ *
2061
+ * What makes this cheaper than reconciling an array of Templates is that a row still holding
2062
+ * the item it was built from needs no Template at all: it is recognized by one identity
2063
+ * check, with nothing built and nothing compared. When the list is the same length and only
2064
+ * a few rows changed, that is the whole render — see applyMisses(). Otherwise the walk
2065
+ * follows the offset a shifted list settles on, and finally consults a map from item to the
2066
+ * Template the previous render built, so rows that moved far are still reused.
2067
+ * @param mapped {MappedList} */
2068
+ applyMapped(mapped) {
2069
+ let items = mapped.items, fn = mapped.fn;
2070
+ let len = items.length;
2071
+ let oldNgs = this.nodeGroups;
2072
+ // Only rows this path drew from an h.map() last time can be recognized by their item;
2073
+ // anything else starts over.
2074
+ let lastItems = this.lastItems;
2075
+ let oldLen = oldNgs === null || lastItems === null || lastItems.length !== oldNgs.length
2076
+ ? 0 : oldNgs.length;
2077
+
2078
+ // Patch path. When the list is the same length as last time, every row that still holds
2079
+ // the item it was built from is already final: it needs no Template, no comparison and no
2080
+ // visit. So find the positions that did change, build only those, and patch them. That
2081
+ // makes a selection or a partial update cost work proportional to the change instead of
2082
+ // to the length of the list. Rows that must be visited even when unchanged (components,
2083
+ // live HTML properties) rule it out, since revisiting them is what the full scan is for.
2084
+ let misses = null, missTemplates = null, missCount = 0;
2085
+ if (oldLen === len && len !== 0 && !this.anyNeedsRefresh && !this.itemsHaveNodes) {
2086
+ let tooMany = false;
2087
+ let cap = missProbeThreshold;
2088
+
2089
+ // First find WHICH positions changed, without building anything for them. A change
2090
+ // this path can't handle is then abandoned having cost only comparisons — building
2091
+ // as we went would throw away a Template for every row of, say, a reversed list,
2092
+ // which the general diff is about to reuse from the previous render.
2093
+ for (let i=0; i<len; i++) {
2094
+ if (lastItems[i] !== items[i]) {
2095
+ if (missCount === cap) {
2096
+ // Enough of the list has changed to ask what kind of change this is,
2097
+ // because the two kinds want opposite treatment. If the item at this
2098
+ // position is somewhere else in the old list, the rows were reordered,
2099
+ // and the general diff's item map will reuse their Templates instead of
2100
+ // rebuilding them — so stop here and let it. If the item is new, the
2101
+ // rows' contents changed, and there is nothing to reuse: keep going and
2102
+ // patch them all, however many there are. The scan costs one pass over
2103
+ // the old rows, once, and only for a list that changed this much.
2104
+ if (itemIsElsewhere(lastItems, oldLen, items[i])) {
2105
+ tooMany = true;
2106
+ missCount = 0; // Nothing was built, so the general path has nothing to reuse.
2107
+ break;
2108
+ }
2109
+ cap = len; // Asked and answered; there is no second probe.
2110
+ }
2111
+ (misses ??= [])[missCount++] = i;
2112
+ }
2113
+ }
2114
+
2115
+ // Now build them.
2116
+ if (!tooMany && missCount !== 0) {
2117
+ missTemplates = new Array(missCount);
2118
+ for (let k=0; k<missCount; k++) {
2119
+ let t = fn(items[misses[k]]);
2120
+ if (!(t instanceof Template) && typeof t !== 'string') { // A Node, an array, …
2121
+ tooMany = true;
2122
+ missCount = k; // Keep the ones already built; the rest are the caller's problem.
2123
+ break;
2124
+ }
2125
+ missTemplates[k] = t;
2126
+ }
2127
+ }
2128
+ if (!tooMany && (missCount === 0
2129
+ || this.applyMisses(oldNgs, misses, missTemplates, missCount, len))) {
2130
+ for (let k=0; k<missCount; k++) {
2131
+ let j = misses[k];
2132
+ lastItems[j] = items[j];
2133
+ }
2134
+ return;
2135
+ }
2136
+ }
2137
+
2138
+ // General path: build the whole list of Templates and hand it to the reconciler.
2139
+ let newItems = new Array(len);
2140
+ let built = missCount !== 0 ? misses : null, b = 0;
2141
+ let itemMap = null, noItemMap = false;
2142
+ const indexOfItem = item => {
2143
+ if (noItemMap)
2144
+ return -1;
2145
+ if (itemMap === null) {
2146
+ // One scan before paying for a map: if this item is nowhere in the old rows, the
2147
+ // list's contents changed rather than moved, so there is nothing to look up and
2148
+ // every later miss can go straight to the callback. A scan is cheaper than a map
2149
+ // of every row, and this is the common shape — rows replaced in place.
2150
+ if (!itemIsElsewhere(lastItems, oldLen, item)) {
2151
+ noItemMap = true;
2152
+ return -1;
2153
+ }
2154
+ itemMap = new Map();
2155
+ for (let k=0; k<oldLen; k++)
2156
+ itemMap.set(lastItems[k], k);
2157
+ }
2158
+ let k = itemMap.get(item);
2159
+ return k === undefined ? -1 : k;
2160
+ };
2161
+ // Walk the two lists together. A row is recognized by the item it was built from, at the
2162
+ // offset the walk has settled on: after an insertion or a removal every later row sits a
2163
+ // fixed distance from where it was, and following that keeps recognizing them instead of
2164
+ // treating the whole tail as changed. The short search that re-establishes the offset
2165
+ // only runs while the walk is still in step, so a list of genuinely new rows (an append,
2166
+ // a replace-all) gives up after one miss rather than searching for every row. Failing
2167
+ // all that, a map from item to the Template the previous render built for it catches
2168
+ // rows that moved far — a sort, a shuffle. It's built on demand, from the rows this
2169
+ // path already holds: a persistent per-item cache would instead pay a write for every
2170
+ // row of every list ever created, which is most of the work of building a list from
2171
+ // scratch, and would hold each Template alive for as long as the caller holds the item.
2172
+ if (oldLen !== 0) {
2173
+ let delta = 0, inSync = true;
2174
+ for (let i=0; i<len; i++) {
2175
+ let item = items[i];
2176
+ let j = i + delta;
2177
+ let inRange = j >= 0 && j < oldLen;
2178
+ if (inRange && lastItems[j] === item) {
2179
+ newItems[i] = oldNgs[j].template;
2180
+ inSync = true;
2181
+ continue;
2182
+ }
2183
+
2184
+ // This position was already found to have changed, and its Template built, by the
2185
+ // patch scan above. That only happens for a same-length list, where the offset
2186
+ // stays zero, so there's no search to redo here.
2187
+ if (built !== null && b < missCount && built[b] === i) {
2188
+ newItems[i] = missTemplates[b++];
2189
+ continue;
2190
+ }
2191
+
2192
+ if (inSync) {
2193
+ let found = -1;
2194
+ for (let d=1; d<=shiftSearchDistance; d++) {
2195
+ let after = j + d, before = j - d;
2196
+ if (after < oldLen && lastItems[after] === item) {
2197
+ found = after;
2198
+ break;
2199
+ }
2200
+ if (before >= 0 && lastItems[before] === item) {
2201
+ found = before;
2202
+ break;
2203
+ }
2204
+ }
2205
+ if (found >= 0) {
2206
+ delta = found - i;
2207
+ newItems[i] = oldNgs[found].template;
2208
+ continue;
2209
+ }
2210
+
2211
+ // The item isn't in the old list at all, but the old row standing here
2212
+ // belongs to an item a little further along: rows were INSERTED here. Build
2213
+ // this one and shift the offset, so the rest of the list is still recognized.
2214
+ // Without this, prepending one row to a long list would look like a change to
2215
+ // every row in it. Only worth asking when the list actually grew.
2216
+ if (inRange && len > oldLen)
2217
+ for (let d=1; d<=insertSearchDistance && i+d<len; d++)
2218
+ if (items[i+d] === lastItems[j]) {
2219
+ newItems[i] = fn(item);
2220
+ delta--;
2221
+ found = -2; // Handled; skip the fallbacks below.
2222
+ break;
2223
+ }
2224
+ if (found === -2)
2225
+ continue;
2226
+
2227
+ inSync = false;
2228
+ }
2229
+
2230
+ // Past the end of the old list there is nothing left to match, so appended rows
2231
+ // go straight to the callback instead of paying for a lookup that must miss.
2232
+ if (j < oldLen) {
2233
+ let k = indexOfItem(item);
2234
+ if (k >= 0) {
2235
+ newItems[i] = oldNgs[k].template;
2236
+ delta = k - i; // Back in step; the rest of the list can walk positionally again.
2237
+ inSync = true;
2238
+ continue;
2239
+ }
2240
+ }
2241
+ newItems[i] = fn(item);
2242
+ }
2243
+ }
2244
+
2245
+ else
2246
+ for (let i=0; i<len; i++)
2247
+ newItems[i] = fn(items[i]);
2248
+
2249
+ // A callback that returns something other than a Template or a string (a raw Node, an
2250
+ // array, a nested list) can't be diffed positionally; flatten it the general way.
2251
+ let first = len !== 0 ? newItems[0] : null;
2252
+ if (first !== null && !(first instanceof Template) && typeof first !== 'string') {
2253
+ let flat = [];
2254
+ let hasNodesNow = this.collectItems(newItems, flat, false);
2255
+ if (hasNodesNow || this.itemsHaveNodes) {
2256
+ this.itemsHaveNodes = hasNodesNow;
2257
+ this.applyGeneric(flat);
2258
+ }
1662
2259
  else
1663
- this.applyDiff(newItems);
2260
+ this.diffItems(flat);
2261
+ return;
1664
2262
  }
1665
2263
 
1666
-
2264
+ if (this.itemsHaveNodes) {
2265
+ this.itemsHaveNodes = false;
2266
+ this.applyGeneric(newItems);
2267
+ return;
2268
+ }
2269
+
2270
+ this.diffItems(newItems);
2271
+
2272
+ // Remember which item drew each row, so the next render can match them by identity.
2273
+ // The reconciler leaves nodeGroups aligned with newItems, and therefore with items.
2274
+ // The caller's array is copied rather than kept, since the caller mutates it in place.
2275
+ let li = this.lastItems;
2276
+ if (li === null || li.length !== len)
2277
+ li = this.lastItems = new Array(len);
2278
+ for (let j=0; j<len; j++)
2279
+ li[j] = items[j];
2280
+ }
2281
+
2282
+ /**
2283
+ * Patch only the positions an h.map() render changed, leaving every other row alone.
2284
+ *
2285
+ * Every unchanged position already holds the NodeGroup built from that exact item, so it
2286
+ * needs no visit at all; only the changed positions can require a rewrite, a move, or a new
2287
+ * row. Changed positions are handled in two steps, the same shape as the general keyed
2288
+ * diff's small-reorder path: first the ones that kept their key (a row whose data changed
2289
+ * in place), then the leftovers are cross-matched against each other by key so a swap or a
2290
+ * short shuffle moves the fewest node ranges.
2291
+ *
2292
+ * @param ngs {NodeGroup[]} This path's NodeGroups, patched in place.
2293
+ * @param misses {int[]} Positions whose item changed, ascending.
2294
+ * @param templates {(Template|string)[]} The new Template for each of those positions.
2295
+ * @param missCount {int}
2296
+ * @param len {int} Length of the list, for anchoring the last position.
2297
+ * @return {boolean} False when the change doesn't fit this path and the caller must run
2298
+ * the general diff instead; nothing has been modified in that case. */
2299
+ applyMisses(ngs, misses, templates, missCount, len) {
2300
+
2301
+ // Only a keyed list can move rows around safely. An unkeyed one can still be rewritten
2302
+ // in place, which is what the positional diff would do for it anyway.
2303
+ let keyed = ngs[0].key !== undefined;
2304
+
2305
+ // 1. Classify the changed positions without touching anything, so that a change too big
2306
+ // for this path can still be handed to the general diff with nothing half-applied.
2307
+ // A row that kept its key is rewritten where it stands; the rest have to be matched
2308
+ // against each other, and past a handful of those the general diff's map-and-LIS
2309
+ // approach is the better tool.
2310
+ let displaced = null, dCount = 0;
2311
+ for (let k=0; k<missCount; k++) {
2312
+ let ng = ngs[misses[k]], t = templates[k];
2313
+ if (typeof t === 'string' || !itemClose(ng, t) || (keyed && ng.key !== keyOf(t))) {
2314
+ if (!keyed || dCount === maxDisplacedMisses)
2315
+ return false;
2316
+ (displaced ??= [])[dCount++] = k;
2317
+ }
2318
+ }
2319
+
2320
+ // 2. Rewrite the rows that kept their key. displaced holds indexes into misses in
2321
+ // ascending order, so one pointer walks past them.
2322
+ for (let k=0, d=0; k<missCount; k++) {
2323
+ if (d < dCount && displaced[d] === k) {
2324
+ d++;
2325
+ continue;
2326
+ }
2327
+ let ng = ngs[misses[k]], t = templates[k];
2328
+ if (itemSame(ng, t))
2329
+ this.refreshSameItem(ng, t);
2330
+ else
2331
+ this.rewriteNodeGroup(ng, t);
2332
+ }
2333
+ if (dCount === 0)
2334
+ return true;
2335
+
2336
+ // 3. Hand the displaced rows to the shared placer. displaced holds indexes into misses
2337
+ // and templates, so misses is what maps a row to its position in the list.
2338
+ let wholeParent = this.wholeParent;
2339
+ this.placeDisplaced(displaced, misses, ngs, templates, ngs, len,
2340
+ wholeParent ? null : this.nodeMarker,
2341
+ wholeParent ? this.nodeMarker : this.nodeMarker.parentNode);
2342
+
2343
+ // 4. Node membership or order changed, so invalidate caches.
2344
+ if (!this.parentNg.firstApply) {
2345
+ this.nodesCache = null;
2346
+ if (this.parentNg.parentPath)
2347
+ this.parentNg.parentPath.clearNodesCache();
2348
+ }
2349
+
2350
+ // Keep state used by the generic path from going stale.
2351
+ if (this.nodeGroupsAttachedAvailable)
2352
+ this.nodeGroupsAttachedAvailable = null;
2353
+ return true;
2354
+ }
2355
+
2356
+ /**
2357
+ * Settle a handful of rows that moved, appeared or vanished within one window of a list.
2358
+ *
2359
+ * Both small-reorder paths — the h.map() patch in applyMisses and the equal-length window in
2360
+ * applyKeyed — reach the same point: a few positions whose old NodeGroup no longer belongs
2361
+ * where it stands, everything around them already correct. Since every candidate came from
2362
+ * this same window, a swap, a dragged row or a short shuffle finds its partners inside it, so
2363
+ * the rows are cross-matched against each other by key rather than through the general
2364
+ * diff's key map and longest-increasing-subsequence machinery.
2365
+ *
2366
+ * rows holds ascending indexes into items, which is the array each caller already has; when
2367
+ * those indexes are not themselves list positions, positions maps them across. Doing the
2368
+ * indirection here rather than compacting it away in the caller keeps this off the allocation
2369
+ * path: neither caller builds an array it wasn't building already. rows.length is small by
2370
+ * construction (at most maxDisplacedMisses), which is what makes the O(n²) cross-match
2371
+ * cheaper than building a map.
2372
+ *
2373
+ * @param rows {int[]} Ascending indexes of the rows to settle.
2374
+ * @param positions {int[]|null} Maps a row index to its list position, or null when the row
2375
+ * indexes are already positions.
2376
+ * @param oldNgs {NodeGroup[]} Where each position's outgoing NodeGroup is read from.
2377
+ * @param items {(Template|string)[]} The new items, indexed by row index.
2378
+ * @param outNgs {NodeGroup[]} Receives the NodeGroup that ends up at each position. May be
2379
+ * the same array as oldNgs; the outgoing groups are snapshotted before anything is written.
2380
+ * @param boundary {int} First position past this window, where the anchor stops being
2381
+ * outNgs[p+1] and becomes tailAnchor.
2382
+ * @param tailAnchor {Node|null} Anchor for a row placed at boundary-1.
2383
+ * @param parent {Node} Where the rows' nodes live. */
2384
+ placeDisplaced(rows, positions, oldNgs, items, outNgs, boundary, tailAnchor, parent) {
2385
+ let count = rows.length;
2386
+
2387
+ // 1. Cross-match the rows against each other by key. A claimed NodeGroup is nulled out
2388
+ // of the snapshot so it can't be claimed twice.
2389
+ let free = new Array(count);
2390
+ for (let b=0; b<count; b++) {
2391
+ let i = rows[b];
2392
+ free[b] = oldNgs[positions === null ? i : positions[i]];
2393
+ }
2394
+ let placed = new Array(count);
2395
+ for (let a=0; a<count; a++) {
2396
+ let t = items[rows[a]];
2397
+ let key = keyOf(t);
2398
+ if (key !== undefined)
2399
+ for (let b=0; b<count; b++) {
2400
+ let ng = free[b];
2401
+ if (ng !== null && ng.key === key && itemClose(ng, t)) {
2402
+ free[b] = null;
2403
+ if (itemSame(ng, t))
2404
+ this.refreshSameItem(ng, t);
2405
+ else
2406
+ this.rewriteNodeGroup(ng, t);
2407
+ placed[a] = ng;
2408
+ break;
2409
+ }
2410
+ }
2411
+ }
2412
+
2413
+ // 2. Discard the old rows nothing claimed. Keyed semantics require a new key to get new
2414
+ // nodes, so these are never pooled.
2415
+ for (let b=0; b<count; b++) {
2416
+ let ng = free[b];
2417
+ if (ng !== null) {
2418
+ if (ng.startNode !== ng.endNode)
2419
+ Util.saveOrphans(ng.getNodes());
2420
+ else
2421
+ ng.startNode.remove();
2422
+ }
2423
+ }
2424
+
2425
+ // 3. Put the rows in place, right to left so each one's anchor is already final.
2426
+ for (let a=count-1; a>=0; a--) {
2427
+ let i = rows[a];
2428
+ let p = positions === null ? i : positions[i];
2429
+ let ng = placed[a];
2430
+ if (ng === undefined)
2431
+ ng = this.createNew(items[i]);
2432
+ outNgs[p] = ng;
2433
+ let anchor = p+1 < boundary ? outNgs[p+1].startNode : tailAnchor;
2434
+ if (ng.endNode.nextSibling !== anchor || ng.startNode.parentNode !== parent)
2435
+ insertNodesBefore(parent, ng, anchor);
2436
+ }
1667
2437
  }
1668
2438
 
1669
2439
  /**
@@ -1686,8 +2456,8 @@ class PathToNodes extends Path {
1686
2456
  let ng = oldNgs[start], t = newItems[start];
1687
2457
  if (!itemSame(ng, t))
1688
2458
  break;
1689
- if (ng.hasComponentPaths)
1690
- ng.applyExprs(t.exprs, false);
2459
+ if (ng.shell.needsRefresh)
2460
+ this.refreshSameItem(ng, t);
1691
2461
  newNgs[start] = ng;
1692
2462
  start++;
1693
2463
  }
@@ -1697,8 +2467,8 @@ class PathToNodes extends Path {
1697
2467
  let ng = oldNgs[oldEnd-1], t = newItems[newEnd-1];
1698
2468
  if (!itemSame(ng, t))
1699
2469
  break;
1700
- if (ng.hasComponentPaths)
1701
- ng.applyExprs(t.exprs, false);
2470
+ if (ng.shell.needsRefresh)
2471
+ this.refreshSameItem(ng, t);
1702
2472
  newNgs[--newEnd] = ng;
1703
2473
  oldEnd--;
1704
2474
  }
@@ -1707,8 +2477,8 @@ class PathToNodes extends Path {
1707
2477
  while (start < oldEnd && start < newEnd) {
1708
2478
  let ng = oldNgs[start], t = newItems[start];
1709
2479
  if (itemSame(ng, t)) { // Can happen between changed rows, e.g. partial updates.
1710
- if (ng.hasComponentPaths)
1711
- ng.applyExprs(t.exprs, false);
2480
+ if (ng.shell.needsRefresh)
2481
+ this.refreshSameItem(ng, t);
1712
2482
  }
1713
2483
  else if (itemClose(ng, t))
1714
2484
  this.rewriteNodeGroup(ng, t);
@@ -1745,34 +2515,18 @@ class PathToNodes extends Path {
1745
2515
  }
1746
2516
  }
1747
2517
 
1748
- // 5. Insert leftover new items.
2518
+ // 5. Insert leftover new items directly. Each row is one native insert; a
2519
+ // batching DocumentFragment would double the insert count for no benefit,
2520
+ // since style/layout work is deferred until the next frame either way.
1749
2521
  if (newRemain) {
1750
2522
  let wholeParent = this.wholeParent;
1751
2523
  let anchor = newEnd < newLen ? newNgs[newEnd].startNode : (wholeParent ? null : this.nodeMarker);
1752
2524
  let parent = wholeParent ? this.nodeMarker : this.nodeMarker.parentNode;
1753
- let target = parent, before = anchor;
1754
- let fragment = null;
1755
- if (newRemain > 1) { // Batch-insert through a fragment.
1756
- fragment = Globals$1.doc.createDocumentFragment();
1757
- target = fragment;
1758
- before = null;
1759
- }
1760
2525
  for (let i=start; i<newEnd; i++) {
1761
2526
  let ng = this.createOrReuse(newItems[i]);
1762
2527
  newNgs[i] = ng;
1763
- let node = ng.startNode, end = ng.endNode;
1764
- if (node === end) // Single-node NodeGroups are the common case in loops.
1765
- target.insertBefore(node, before);
1766
- else while (true) {
1767
- let next = node.nextSibling;
1768
- target.insertBefore(node, before);
1769
- if (node === end)
1770
- break;
1771
- node = next;
1772
- }
2528
+ insertNodesBefore(parent, ng, anchor);
1773
2529
  }
1774
- if (fragment)
1775
- parent.insertBefore(fragment, anchor);
1776
2530
  }
1777
2531
 
1778
2532
  // 6. Node membership changed, so invalidate caches.
@@ -1787,8 +2541,6 @@ class PathToNodes extends Path {
1787
2541
  this.nodeGroups = newNgs;
1788
2542
 
1789
2543
  // Keep state used by the generic path from going stale.
1790
- if (this.nodeGroupsRendered)
1791
- this.nodeGroupsRendered = null;
1792
2544
  if (this.nodeGroupsAttachedAvailable)
1793
2545
  this.nodeGroupsAttachedAvailable = null;
1794
2546
  }
@@ -1807,18 +2559,6 @@ class PathToNodes extends Path {
1807
2559
  let oldLen = oldNgs.length, newLen = newItems.length;
1808
2560
  let newNgs = new Array(newLen);
1809
2561
 
1810
- // Resolve an item's key, caching the html->keyIndex lookup for same-template lists.
1811
- let keyHtml = null, keyIndex = -1;
1812
- const keyOf = t => {
1813
- if (t.key !== undefined) // JSX templates carry the key directly.
1814
- return t.key;
1815
- if (t.html !== keyHtml) {
1816
- keyHtml = t.html;
1817
- keyIndex = Shell.get(t.html, t.svgMode).keyIndex;
1818
- }
1819
- return keyIndex >= 0 ? t.exprs[keyIndex] : undefined;
1820
- };
1821
-
1822
2562
 
1823
2563
 
1824
2564
  let start = 0, oldEnd = oldLen, newEnd = newLen;
@@ -1828,15 +2568,13 @@ class PathToNodes extends Path {
1828
2568
  let ng = oldNgs[start], t = newItems[start];
1829
2569
  // An identical Template instance (h.map) implies an identical key, so skip key extraction.
1830
2570
  if (ng.template === t) {
1831
- if (ng.hasComponentPaths)
1832
- ng.applyExprs(t.exprs, false);
2571
+ if (ng.shell.needsRefresh)
2572
+ this.refreshSameItem(ng, t);
1833
2573
  }
1834
2574
  else if (typeof t === 'string' || ng.key !== keyOf(t) || !itemClose(ng, t))
1835
2575
  break;
1836
- else if (itemSame(ng, t)) {
1837
- if (ng.hasComponentPaths)
1838
- ng.applyExprs(t.exprs, false);
1839
- }
2576
+ else if (itemSame(ng, t))
2577
+ this.refreshSameItem(ng, t);
1840
2578
  else
1841
2579
  this.rewriteNodeGroup(ng, t);
1842
2580
  newNgs[start] = ng;
@@ -1847,15 +2585,13 @@ class PathToNodes extends Path {
1847
2585
  while (oldEnd > start && newEnd > start) {
1848
2586
  let ng = oldNgs[oldEnd-1], t = newItems[newEnd-1];
1849
2587
  if (ng.template === t) {
1850
- if (ng.hasComponentPaths)
1851
- ng.applyExprs(t.exprs, false);
2588
+ if (ng.shell.needsRefresh)
2589
+ this.refreshSameItem(ng, t);
1852
2590
  }
1853
2591
  else if (typeof t === 'string' || ng.key !== keyOf(t) || !itemClose(ng, t))
1854
2592
  break;
1855
- else if (itemSame(ng, t)) {
1856
- if (ng.hasComponentPaths)
1857
- ng.applyExprs(t.exprs, false);
1858
- }
2593
+ else if (itemSame(ng, t))
2594
+ this.refreshSameItem(ng, t);
1859
2595
  else
1860
2596
  this.rewriteNodeGroup(ng, t);
1861
2597
  newNgs[--newEnd] = ng;
@@ -1867,6 +2603,57 @@ class PathToNodes extends Path {
1867
2603
  let wholeParent = this.wholeParent;
1868
2604
  let parent = wholeParent ? this.nodeMarker : this.nodeMarker.parentNode;
1869
2605
 
2606
+ // 3a. Equal-length windows: scan them aligned. Rows whose keys match positionally
2607
+ // are updated in place with no bookkeeping, and when at most 8 positions are
2608
+ // displaced (a swap, a dragged row, a small shuffle) they're cross-matched and
2609
+ // moved directly — no key map, no sources array, no LIS. A bigger shuffle falls
2610
+ // through to the general map phase; the in-place updates already done stay valid
2611
+ // there, since the map phase finds those rows already matching their new items.
2612
+ let fastHandled = false;
2613
+ if (oldRemain === newRemain) {
2614
+ let displaced = null;
2615
+ let ok = true;
2616
+ for (let i=start; i<newEnd; i++) {
2617
+ let ng = oldNgs[i], t = newItems[i];
2618
+ if (ng.template === t) {
2619
+ if (ng.shell.needsRefresh)
2620
+ this.refreshSameItem(ng, t);
2621
+ }
2622
+ else {
2623
+ let k = keyOf(t);
2624
+ if (k !== undefined && ng.key === k && itemClose(ng, t)) {
2625
+ if (itemSame(ng, t))
2626
+ this.refreshSameItem(ng, t);
2627
+ else
2628
+ this.rewriteNodeGroup(ng, t);
2629
+ }
2630
+ else {
2631
+ (displaced ??= []).push(i);
2632
+ if (displaced.length > 8) {
2633
+ ok = false;
2634
+ break;
2635
+ }
2636
+ continue; // newNgs[i] is filled during the placement pass below.
2637
+ }
2638
+ }
2639
+ newNgs[i] = ng;
2640
+ }
2641
+ if (ok) {
2642
+ // The windows are the same length, so a displaced row's index is already its
2643
+ // position and no position map is needed. The tail anchor is the suffix row
2644
+ // just past this window, which placement never writes to — it only fills
2645
+ // positions below newEnd — so it is computed once here instead of on every
2646
+ // pass around the placement loop.
2647
+ if (displaced !== null)
2648
+ this.placeDisplaced(displaced, null, oldNgs, newItems, newNgs, newEnd,
2649
+ newEnd < newLen ? newNgs[newEnd].startNode : (wholeParent ? null : this.nodeMarker),
2650
+ parent);
2651
+ fastHandled = true;
2652
+ }
2653
+ }
2654
+
2655
+ if (!fastHandled) {
2656
+
1870
2657
  // 3. Match the middle windows by key.
1871
2658
  let kept = 0, moved = false;
1872
2659
  let sources = null; // sources[i] = old index reused by new item start+i, or -1 to create fresh.
@@ -1892,10 +2679,8 @@ class PathToNodes extends Path {
1892
2679
  moved = true;
1893
2680
  else
1894
2681
  lastNewIndex = newIndex;
1895
- if (itemSame(ng, t)) {
1896
- if (ng.hasComponentPaths)
1897
- ng.applyExprs(t.exprs, false);
1898
- }
2682
+ if (itemSame(ng, t))
2683
+ this.refreshSameItem(ng, t);
1899
2684
  else
1900
2685
  this.rewriteNodeGroup(ng, t);
1901
2686
  newNgs[newIndex] = ng;
@@ -1904,59 +2689,72 @@ class PathToNodes extends Path {
1904
2689
  (removals ??= []).push(ng);
1905
2690
  }
1906
2691
  }
1907
- else {
1908
- removals = oldNgs.slice(start, oldEnd);
1909
- }
2692
+ // else: the whole old window goes away. It isn't collected into an array here,
2693
+ // because the fast clear below usually takes every one of them at once and the
2694
+ // array would be built only to be thrown away.
2695
+ }
2696
+
2697
+ // 3b. A large whole-parent list that is being fully replaced is emptied and refilled
2698
+ // with its parent detached, so the browser's connected-tree bookkeeping (child-change
2699
+ // notifications, tree-version bumps, MutationObserver interest walks, deferred
2700
+ // accessibility and style consumers) runs once at reattach instead of once per row
2701
+ // removed and once per row added. Detaching before the clear, rather than after it,
2702
+ // puts the removals on the cheap side of that line as well. The gates: the whole
2703
+ // region is being replaced, so nothing is kept and no focus can survive inside it;
2704
+ // the parent is a plain element, since detaching a custom element would fire its
2705
+ // disconnected/connectedCallback in the middle of a render and a subclass may run
2706
+ // arbitrary logic there; the parent is in the document, since the notification storm
2707
+ // only exists on a connected tree; and the list is long enough for the saving to beat
2708
+ // the fixed cost of the detour and the extra MutationObserver records it creates.
2709
+ let detachedFrom = null, reattachBefore = null;
2710
+ if (wholeParent && start === 0 && newEnd === newLen && kept === 0 && newRemain > 500
2711
+ && parent.isConnected && parent.parentNode !== null
2712
+ && parent.localName.indexOf('-') === -1 && !parent.hasAttribute('is')) {
2713
+ detachedFrom = parent.parentNode;
2714
+ reattachBefore = parent.nextSibling;
2715
+ parent.remove();
1910
2716
  }
1911
2717
 
1912
2718
  // 4. Remove unmatched old NodeGroups. They're discarded, never pooled,
1913
2719
  // so a later render with new keys always creates new nodes.
1914
- if (removals) {
1915
- // Materialize node caches of multi-node groups while attached, since detaching breaks sibling links.
1916
- for (let ng of removals)
1917
- if (ng.startNode !== ng.endNode)
1918
- ng.getNodes();
2720
+ let removeAll = oldRemain !== 0 && newRemain === 0;
2721
+ if (removals !== null || removeAll) {
2722
+ // Fast clear when nothing is kept anywhere; the whole region is removals. Trying
2723
+ // it first means a cleared list skips the two passes below entirely: those exist
2724
+ // to lift each group's nodes out one at a time, and emptying the parent has
2725
+ // already taken all of them.
2726
+ if (!(start === 0 && newEnd === newLen && kept === 0 && this.fastClear())) {
2727
+ if (removeAll)
2728
+ removals = oldNgs.slice(start, oldEnd);
2729
+
2730
+ // Materialize node caches of multi-node groups while attached, since detaching breaks sibling links.
2731
+ for (let ng of removals)
2732
+ if (ng.startNode !== ng.endNode)
2733
+ ng.getNodes();
1919
2734
 
1920
- // Fast clear when nothing is kept anywhere; the whole region is removals.
1921
- let cleared = start === 0 && newEnd === newLen && kept === 0 && this.fastClear();
1922
- if (!cleared)
1923
2735
  for (let ng of removals) {
1924
2736
  if (ng.startNode !== ng.endNode)
1925
2737
  Util.saveOrphans(ng.getNodes()); // Moves the nodes out of the DOM, into their own fragment.
1926
2738
  else
1927
2739
  ng.startNode.remove();
1928
2740
  }
2741
+ }
1929
2742
  }
1930
2743
 
1931
2744
  // 5. Insert new NodeGroups and move kept ones.
1932
2745
  if (newRemain) {
1933
2746
  let anchor = newEnd < newLen ? newNgs[newEnd].startNode : (wholeParent ? null : this.nodeMarker);
1934
2747
 
1935
- // 5a. Nothing kept in the middle: batch-insert every new item through a fragment.
2748
+ // 5a. Nothing kept in the middle: insert every new item directly.
2749
+ // Each row is one native insert; routing rows through a batching
2750
+ // DocumentFragment would double the insert count for no benefit, since
2751
+ // style/layout work is deferred until the next frame either way.
1936
2752
  if (kept === 0) {
1937
- let target = parent, before = anchor;
1938
- let fragment = null;
1939
- if (newRemain > 1) {
1940
- fragment = Globals$1.doc.createDocumentFragment();
1941
- target = fragment;
1942
- before = null;
1943
- }
1944
2753
  for (let i=start; i<newEnd; i++) {
1945
2754
  let ng = this.createNew(newItems[i]);
1946
2755
  newNgs[i] = ng;
1947
- let node = ng.startNode, end = ng.endNode;
1948
- if (node === end)
1949
- target.insertBefore(node, before);
1950
- else while (true) {
1951
- let next = node.nextSibling;
1952
- target.insertBefore(node, before);
1953
- if (node === end)
1954
- break;
1955
- node = next;
1956
- }
2756
+ insertNodesBefore(parent, ng, anchor);
1957
2757
  }
1958
- if (fragment)
1959
- parent.insertBefore(fragment, anchor);
1960
2758
  }
1961
2759
 
1962
2760
  // 5b. Mixed: iterate backwards so each item's anchor is already in place.
@@ -1983,6 +2781,11 @@ class PathToNodes extends Path {
1983
2781
  }
1984
2782
  }
1985
2783
 
2784
+ if (detachedFrom !== null)
2785
+ detachedFrom.insertBefore(parent, reattachBefore);
2786
+
2787
+ } // end if (!fastHandled)
2788
+
1986
2789
  // 6. Node membership or order changed, so invalidate caches.
1987
2790
  // During a NodeGroup's first applyExprs(), no ancestor caches can reference its nodes yet.
1988
2791
  if (!this.parentNg.firstApply) {
@@ -1995,8 +2798,6 @@ class PathToNodes extends Path {
1995
2798
  this.nodeGroups = newNgs;
1996
2799
 
1997
2800
  // Keep state used by the generic path from going stale.
1998
- if (this.nodeGroupsRendered)
1999
- this.nodeGroupsRendered = null;
2000
2801
  if (this.nodeGroupsAttachedAvailable)
2001
2802
  this.nodeGroupsAttachedAvailable = null;
2002
2803
  }
@@ -2010,11 +2811,30 @@ class PathToNodes extends Path {
2010
2811
  if (typeof item === 'string')
2011
2812
  return new NodeGroup(textTemplate(item), this); // Text NodeGroups have no paths to apply.
2012
2813
  let ng = new NodeGroup(item, this);
2814
+ if (ng.shell.needsRefresh)
2815
+ this.anyNeedsRefresh = true;
2013
2816
  if (item.exprs.length || (ng.paths && ng.paths.length))
2014
2817
  ng.applyExprs(item.exprs);
2015
2818
  return ng;
2016
2819
  }
2017
2820
 
2821
+ /**
2822
+ * Refresh a NodeGroup whose new template has the SAME values as its current one.
2823
+ * Components still render so changes deeper in the tree can surface, and groups holding
2824
+ * live-HTML-property bindings (checked/value/selected) rewrite in place — a user's click
2825
+ * flips those DOM properties underneath the cached expression, so same values ≠ same DOM.
2826
+ * rewriteNodeGroup's per-path skip exempts exactly those paths; everything else is
2827
+ * compared and skipped as before, so this stays cheap.
2828
+ * @param ng {NodeGroup}
2829
+ * @param t {Template|string} */
2830
+ refreshSameItem(ng, t) {
2831
+ let shell = ng.shell;
2832
+ if (shell.hasComponentPaths)
2833
+ ng.applyExprs(t.exprs, false);
2834
+ else if (shell.hasLivePropPaths && shell.pathsSingleExpr && typeof t !== 'string')
2835
+ this.rewriteNodeGroup(ng, t);
2836
+ }
2837
+
2018
2838
  /**
2019
2839
  * Update an existing NodeGroup, created from the same html strings, with new values.
2020
2840
  * @param ng {NodeGroup}
@@ -2028,15 +2848,21 @@ class PathToNodes extends Path {
2028
2848
  else {
2029
2849
  // When every path consumes exactly one expression, paths align 1:1 with exprs,
2030
2850
  // so only the expressions that changed need to be applied.
2031
- if (ng.pathsSingleExpr) {
2851
+ if (ng.shell.pathsSingleExpr) {
2032
2852
  // Stamped groups (paths === null) rewrite through the shared stampers and stay
2033
2853
  // path-less, unless a child-node expression stopped being primitive.
2034
2854
  if (ng.paths !== null || !ng.rewriteStamp(item)) {
2035
2855
  let oldExprs = ng.template.exprs, newExprs = item.exprs;
2036
2856
  let paths = ng.paths ?? ng.materializePaths();
2037
- for (let i = paths.length - 1; i >= 0; i--)
2038
- if (!exprSame(oldExprs[i], newExprs[i]))
2039
- paths[i].applySingle(newExprs[i]);
2857
+ for (let i = paths.length - 1; i >= 0; i--) {
2858
+ // Boolean live-HTML-property bindings are exempt from the unchanged-value
2859
+ // skip — a click flips the property underneath the cached expression;
2860
+ // applySingle() compares against the live node before writing.
2861
+ let oldExpr = oldExprs[i], newExpr = newExprs[i];
2862
+ if ((oldExpr !== newExpr && !exprSame(oldExpr, newExpr))
2863
+ || (paths[i].isHtmlProperty && typeof newExpr === 'boolean'))
2864
+ paths[i].applySingle(newExpr);
2865
+ }
2040
2866
  }
2041
2867
 
2042
2868
  if (ng.styles)
@@ -2075,6 +2901,8 @@ class PathToNodes extends Path {
2075
2901
  }
2076
2902
 
2077
2903
  ng = new NodeGroup(item, this);
2904
+ if (ng.shell.needsRefresh)
2905
+ this.anyNeedsRefresh = true;
2078
2906
  if (item.exprs.length || (ng.paths && ng.paths.length))
2079
2907
  ng.applyExprs(item.exprs);
2080
2908
  return ng;
@@ -2102,6 +2930,14 @@ class PathToNodes extends Path {
2102
2930
  else if (typeof expr === 'function')
2103
2931
  hasNodes = this.collectItems(expr(), items, hasNodes);
2104
2932
 
2933
+ // A MappedList nested inside an array or returned from a function can't use the
2934
+ // identity fast path, but it still renders; expand it through the per-item cache.
2935
+ else if (expr instanceof MappedList) {
2936
+ let subItems = expr.items, fn = expr.fn;
2937
+ for (let i=0; i<subItems.length; i++)
2938
+ items.push(fn(subItems[i]));
2939
+ }
2940
+
2105
2941
  else if (expr instanceof NodeList) {
2106
2942
  for (let node of expr)
2107
2943
  items.push(node);
@@ -2249,29 +3085,26 @@ class PathToNodes extends Path {
2249
3085
  || this.nodeGroupsDetachedAvailable?.deleteAny(closeKey);
2250
3086
 
2251
3087
  if (result) {
2252
- if (templatesSame(result.template, template)) {
2253
- // Components still render so changes deeper in the tree can surface.
2254
- if (result.hasComponentPaths)
2255
- result.applyExprs(template.exprs, false);
2256
- }
3088
+ if (templatesSame(result.template, template))
3089
+ this.refreshSameItem(result, template);
2257
3090
  else
2258
3091
  result.applyExprs(template.exprs);
2259
3092
  result.template = template;
2260
3093
  }
2261
3094
  else {
2262
3095
  result = new NodeGroup(template, this);
3096
+ if (result.shell.needsRefresh)
3097
+ this.anyNeedsRefresh = true;
2263
3098
  result.applyExprs(template.exprs);
2264
3099
  }
2265
3100
 
2266
- (this.nodeGroupsRendered ??= []).push(result);
2267
-
2268
3101
 
2269
3102
  return result;
2270
3103
  }
2271
3104
 
2272
3105
 
2273
3106
  /**
2274
- * Move everything from this.nodeGroupsRendered to this.nodeGroupsAttached and nodeGroupsDetached.
3107
+ * Move everything from this.nodeGroups to this.nodeGroupsAttached and nodeGroupsDetached.
2275
3108
  * Called at the beginning of applyGeneric() so it can have NodeGroups to use.
2276
3109
  * TODO: this could run as needed in getNodeGroup? */
2277
3110
  freeNodeGroups() {
@@ -2281,7 +3114,7 @@ class PathToNodes extends Path {
2281
3114
  let detached = (this.nodeGroupsDetachedAvailable ??= new MultiValueMap()).data;
2282
3115
  for (let key in previouslyAttached) {
2283
3116
  let src = previouslyAttached[key];
2284
- let from = src.head || 0; // Skip entries already consumed by deleteAny().
3117
+ let from = src.hd || 0; // Skip entries already consumed by deleteAny().
2285
3118
  let array = detached[key];
2286
3119
  if (!array) {
2287
3120
  array = detached[key] = from ? src.slice(from) : src;
@@ -2289,22 +3122,18 @@ class PathToNodes extends Path {
2289
3122
  array.length = maxPooledPerKey;
2290
3123
  }
2291
3124
  else
2292
- for (let i=from, max=maxPooledPerKey + (array.head || 0); i<src.length && array.length < max; i++)
3125
+ for (let i=from, max=maxPooledPerKey + (array.hd || 0); i<src.length && array.length < max; i++)
2293
3126
  array.push(src[i]);
2294
3127
  }
2295
3128
  }
2296
3129
 
2297
- // Add nodes that were used during render() to nodeGroupsRendered.
2298
- // If the last render used the positional diff, the in-use NodeGroups are in
2299
- // this.nodeGroups instead of nodeGroupsRendered.
2300
- this.nodeGroupsAttachedAvailable = new MultiValueMap();
2301
- let nga = this.nodeGroupsAttachedAvailable;
2302
- let source = this.nodeGroupsRendered?.length ? this.nodeGroupsRendered : this.nodeGroups;
2303
- if (source)
2304
- for (let ng of source)
3130
+ // Offer the NodeGroups the last render left in place for reuse. Every path that renders
3131
+ // NodeGroups the positional diff, the keyed diff and applyGeneric alike — leaves them in
3132
+ // this.nodeGroups, so that one array is always the set still standing in the DOM.
3133
+ let nga = this.nodeGroupsAttachedAvailable = new MultiValueMap();
3134
+ if (this.nodeGroups)
3135
+ for (let ng of this.nodeGroups)
2305
3136
  nga.add(ng.closeKey, ng);
2306
-
2307
- this.nodeGroupsRendered = null;
2308
3137
  }
2309
3138
 
2310
3139
 
@@ -2352,12 +3181,55 @@ class PathToNodes extends Path {
2352
3181
  // Shared empty array for paths whose nodeGroups were never created. Never mutated.
2353
3182
  const emptyNodeGroups = [];
2354
3183
 
3184
+ // How many changed h.map() positions applyMapped() collects before it stops to work out what
3185
+ // kind of change it is looking at (see the probe in applyMapped). Below this every ordinary
3186
+ // edit — a selection, a partial update — is handled without asking.
3187
+ const missProbeThreshold = 256;
3188
+
3189
+ // How many of those positions may need matching against each other before the general keyed
3190
+ // diff, with its key map and longest-increasing-subsequence, becomes the cheaper tool. The
3191
+ // cross-match here is quadratic, which only pays while the number of moved rows is small.
3192
+ const maxDisplacedMisses = 16;
3193
+
3194
+ // How far applyMapped() looks around a position to pick a shifted list's rows back up. One
3195
+ // insertion or removal moves everything by one, which the first step finds; a handful at once
3196
+ // still lands inside this window, and past it the item map takes over.
3197
+ const shiftSearchDistance = 4;
3198
+
3199
+ // How far ahead it looks to recognize a block of inserted rows, by finding the item that the
3200
+ // old row standing here now belongs to. Wider than the search above because inserting a page
3201
+ // of rows at once is ordinary, and because this search only runs while the walk is still in
3202
+ // step and stops it dead the first time it fails — so its worst case is one pass of this many
3203
+ // comparisons per render, against building a map of every row in the list.
3204
+ const insertSearchDistance = 64;
3205
+
2355
3206
  // Most detached NodeGroups kept per close key. Bounds memory growth after very large
2356
3207
  // lists are cleared while keeping pooled rows for every typical re-create pattern.
2357
3208
  // Lowering this (e.g. to 1000) cuts retained memory ~7x after clearing a 10k-row list,
2358
3209
  // but makes re-creating such a list ~2x slower since most rows are built fresh.
2359
3210
  const maxPooledPerKey = 10000;
2360
3211
 
3212
+
3213
+ // Cache for keyOf(): list rows share one html array, so the Shell lookup that finds where the
3214
+ // key=${} expression sits happens once per list rather than once per row.
3215
+ let lastKeyHtml = null, lastKeyIndex = -1;
3216
+
3217
+ /**
3218
+ * The list key of an item, or undefined when it has none.
3219
+ * @param t {Template|string}
3220
+ * @return {*} */
3221
+ function keyOf(t) {
3222
+ if (typeof t === 'string')
3223
+ return undefined;
3224
+ if (t.key !== undefined) // JSX templates carry the key directly.
3225
+ return t.key;
3226
+ if (t.html !== lastKeyHtml) {
3227
+ lastKeyHtml = t.html;
3228
+ lastKeyIndex = Shell.get(t.html, t.svgMode).keyIndex;
3229
+ }
3230
+ return lastKeyIndex >= 0 ? t.exprs[lastKeyIndex] : undefined;
3231
+ }
3232
+
2361
3233
  /**
2362
3234
  * @param text {string}
2363
3235
  * @return {Template} */
@@ -2394,6 +3266,21 @@ function itemClose(ng, item) {
2394
3266
  return tpl.html === item.html && tpl.svgMode === item.svgMode;
2395
3267
  }
2396
3268
 
3269
+ /**
3270
+ * Is this item somewhere in the list the previous render drew, i.e. did it move rather than
3271
+ * appear? A plain scan rather than a map, because it runs once and usually answers on the way
3272
+ * past.
3273
+ * @param lastItems {Array}
3274
+ * @param oldLen {int}
3275
+ * @param item {*}
3276
+ * @return {boolean} */
3277
+ function itemIsElsewhere(lastItems, oldLen, item) {
3278
+ for (let i=0; i<oldLen; i++)
3279
+ if (lastItems[i] === item)
3280
+ return true;
3281
+ return false;
3282
+ }
3283
+
2397
3284
  /**
2398
3285
  * Insert all of ng's nodes before anchor within parent.
2399
3286
  * @param parent {Node}
@@ -2490,12 +3377,6 @@ function reconcileNodes(parentNode, oldNodes, newNodes, before) {
2490
3377
  * matches NodeGroups to new templates by this key. */
2491
3378
  class PathToKey extends Path {
2492
3379
 
2493
- /**
2494
- * @param exprs {Expr[]} Only the first is used. */
2495
- apply(exprs) {
2496
- this.parentNg.key = exprs[0];
2497
- }
2498
-
2499
3380
  applySingle(expr) {
2500
3381
  this.parentNg.key = expr;
2501
3382
  }
@@ -2517,9 +3398,9 @@ class PathToComponent extends Path {
2517
3398
  * Call render() on the component pointed to by this Path.
2518
3399
  * And instantiate it (from a -solarite-placeholder element) if it hasn't been done yet.
2519
3400
  * @param exprs {Expr[][]} Expressions to evaluate for each attribute to pass to the constructor.
2520
- * This is different than other Path.apply() functions which only receive Expr[] and not Expr[][].
3401
+ * This is different than other Path.applyAll() functions which only receive Expr[] and not Expr[][].
2521
3402
  * Because here we're receiving an array of arrays of expressions, one for each dynamic attribute. */
2522
- apply(exprs) {
3403
+ applyAll(exprs) {
2523
3404
 
2524
3405
 
2525
3406
 
@@ -2536,8 +3417,15 @@ class PathToComponent extends Path {
2536
3417
  for (let i=0, attribPath; attribPath = this.attribPaths[i]; i++) {
2537
3418
  if (attribPath instanceof PathToKey) // The list key is never a component arg.
2538
3419
  continue;
3420
+ // Event attributes like onchange=${...} are bound with addEventListener when the
3421
+ // PathToEvent itself is applied. They must not also become constructor fields:
3422
+ // a component that assigns its fields onto itself would set the native on*
3423
+ // property, making the handler fire a second time with only the (event) argument
3424
+ // instead of Solarite's documented (event, element) signature.
3425
+ if (attribPath instanceof PathToEvent)
3426
+ continue;
2539
3427
  if (attribPath instanceof PathToAttribValue) {
2540
- let name = Util.dashesToCamel(attribPath.attrName);
3428
+ let name = Util.dashesToCamel(attribPath.attribName);
2541
3429
 
2542
3430
  // Resolve two way bindimg path before we pass it to the component.
2543
3431
  let value = attribPath.getValue(exprs[i]);
@@ -2559,85 +3447,120 @@ class PathToComponent extends Path {
2559
3447
  }
2560
3448
  }
2561
3449
 
2562
- // 2. Instantiate component on first time.
2563
- let isAttrib = el.getAttribute('_is');
2564
- if (el.tagName.endsWith('-SOLARITE-PLACEHOLDER') || isAttrib) {
2565
-
3450
+ // Constructing a component runs arbitrary user code -- field initializers, the
3451
+ // constructor body, render() -- and that code can build more components, re-entering
3452
+ // this method and overwriting the hand-off parked below. Saving the caller's value
3453
+ // here and restoring it in the finally makes the JS call stack the stack this hand-off
3454
+ // needs, and unlike an explicit stack it cannot leak if construction throws.
3455
+ let prevSlotChildren = Globals$1.currentSlotChildren;
3456
+ try {
3457
+ // 2. Instantiate component on first time.
3458
+ let isAttrib = el.getAttribute('_is');
3459
+ if (el.tagName.endsWith('-SOLARITE-PLACEHOLDER') || isAttrib) {
3460
+
3461
+
3462
+ // 2a. Instantiate component
3463
+ let tagName = (isAttrib || el.tagName.slice(0, -21)).toLowerCase(); // Remove -SOLARITE-PLACEHOLDER
3464
+ let Constructor = customElements.get(tagName);
3465
+
3466
+ // Not defined yet (e.g. the module is being lazily imported): keep the placeholder
3467
+ // and instantiate when the definition lands, like a native custom-element upgrade.
3468
+ // deferredExprs always holds the LATEST exprs so re-renders while undefined win.
3469
+ if (!Constructor) {
3470
+ this.deferredExprs = exprs;
3471
+ if (!this.whenDefinedPending) {
3472
+ this.whenDefinedPending = true;
3473
+ console.warn(`Solarite: <${tagName}> is not defined yet; waiting for customElements.define().`);
3474
+ customElements.whenDefined(tagName).then(() => {
3475
+ this.whenDefinedPending = false;
3476
+ let deferred = this.deferredExprs;
3477
+ this.deferredExprs = null;
3478
+ // Skip if a newer render already instantiated or replaced the placeholder.
3479
+ if (deferred && this.nodeMarker === el && el.tagName.endsWith('-SOLARITE-PLACEHOLDER'))
3480
+ this.applyAll(deferred);
3481
+ });
3482
+ }
3483
+ return;
3484
+ }
2566
3485
 
2567
- // 2a. Instantiate component
2568
- let tagName = (isAttrib || el.tagName.slice(0, -21)).toLowerCase(); // Remove -SOLARITE-PLACEHOLDER
2569
- let Constructor = customElements.get(tagName);
2570
- if (!Constructor)
2571
- throw new Error(`Must call customElements.define('${tagName}', Class) before using it.`);
3486
+ // Hand the children declared inside the component's tag to the RootNodeGroup that
3487
+ // its render() is about to create. There is no other channel: the children have
3488
+ // to be parked before new Constructor(), because a Solarite constructor may call
3489
+ // this.render() itself, and the element that would otherwise carry them does not
3490
+ // exist yet.
3491
+ Globals$1.currentSlotChildren = {Constructor, nodes: [...el.childNodes]};
3492
+ let newEl = new Constructor(attribs);
3493
+
3494
+ // 2b. Copy attributes over.
3495
+ if (isAttrib) {
3496
+ newEl.setAttribute('is', isAttrib);
3497
+ // el.removeAttribute('_is');
3498
+ }
3499
+ for (let attrib of el.attributes)
3500
+ if (attrib.name !== '_is')
3501
+ newEl.setAttribute(attrib.name, attrib.value);
2572
3502
 
2573
- Globals$1.currentSlotChildren = [...el.childNodes]; // TODO: Does this need to be a stack?
2574
- let newEl = new Constructor(attribs);
3503
+ // Set dynamic attributes if they are primitive types.
3504
+ for (let name in attribs) {
3505
+ let val = attribs[name];
3506
+ let valType = typeof val;
3507
+ // Only true and false can reach here, so the undefined/null halves of the
3508
+ // falsy test this used to spell out could never have decided anything.
3509
+ if (valType === 'boolean') {
3510
+ if (val)
3511
+ newEl.setAttribute(name, '');
3512
+ }
2575
3513
 
2576
- // 2b. Copy attributes over.
2577
- if (isAttrib) {
2578
- newEl.setAttribute('is', isAttrib);
2579
- // el.removeAttribute('_is');
2580
- }
2581
- for (let attrib of el.attributes)
2582
- if (attrib.name !== '_is')
2583
- newEl.setAttribute(attrib.name, attrib.value);
2584
-
2585
- // Set dynamic attributes if they are primitive types.
2586
- for (let name in attribs) {
2587
- let val = attribs[name];
2588
- let valType = typeof val;
2589
- if (valType === 'boolean') {
2590
- if (val !== false && val !== undefined && val !== null) // Util.isFalsy() inlined
2591
- newEl.setAttribute(name, '');
3514
+ // If type is a non-boolean primitive, set the attribute value.
3515
+ else if (valType==='string' || valType === 'number' || valType==='bigint')
3516
+ newEl.setAttribute(name, val);
2592
3517
  }
2593
3518
 
2594
- // If type is a non-boolean primitive, set the attribute value.
2595
- else if (valType==='string' || valType === 'number' || valType==='bigint')
2596
- newEl.setAttribute(name, val);
2597
- }
2598
3519
 
3520
+ // 2c. If an id pointed at the placeholder, update it to point to the new element.
3521
+ let id = newEl.getAttribute('data-id') || newEl.getAttribute('id');
3522
+ if (id)
3523
+ delve(this.parentNg.getRootEl(), id.split(/\./g), newEl);
2599
3524
 
2600
- // 2c. If an id pointed at the placeholder, update it to point to the new element.
2601
- let id = newEl.getAttribute('data-id') || newEl.getAttribute('id');
2602
- if (id)
2603
- delve(this.parentNg.getRootNode(), id.split(/\./g), newEl);
3525
+ // 2d. Update paths to use replaced element.
3526
+ let ng = this.parentNg;
3527
+ this.nodeMarker = newEl;
3528
+ for (let path of ng.paths) {
3529
+ if (path.nodeMarker === el)
3530
+ path.nodeMarker = newEl;
3531
+ if (path.nodeBefore === el)
3532
+ path.nodeBefore = newEl;
3533
+ }
3534
+ if (ng.startNode === el)
3535
+ ng.startNode = newEl;
3536
+ if (ng.endNode === el)
3537
+ ng.endNode = newEl;
3538
+
3539
+ // 2f. Call render() if it wasn't called by the constructor.
3540
+ // This must happen before we add it to the DOM which can trigger connectedCallback() -> renderFirstTime()
3541
+ // Because that path renders it without the attribute expressions.
3542
+ if (typeof newEl.render === 'function' && !Globals$1.rendered.has(newEl))
3543
+ newEl.render(attribs, true);
3544
+
3545
+ // 2g. Update attribute paths to use the new element and re-apply them.
3546
+ for (let i=0, attribPath; attribPath = this.attribPaths[i]; i++) {
3547
+ attribPath.parentNg = this.parentNg;
3548
+ attribPath.nodeMarker = newEl;
3549
+ attribPath.applyAll(exprs[i]);
3550
+ }
2604
3551
 
2605
- // 2d. Update paths to use replaced element.
2606
- let ng = this.parentNg;
2607
- this.nodeMarker = newEl;
2608
- for (let path of ng.paths) {
2609
- if (path.nodeMarker === el)
2610
- path.nodeMarker = newEl;
2611
- if (path.nodeBefore === el)
2612
- path.nodeBefore = newEl;
2613
- }
2614
- if (ng.startNode === el)
2615
- ng.startNode = newEl;
2616
- if (ng.endNode === el)
2617
- ng.endNode = newEl;
2618
-
2619
- // 2f. Call render() if it wasn't called by the constructor.
2620
- // This must happen before we add it to the DOM which can trigger connectedCallback() -> renderFirstTime()
2621
- // Because that path renders it without the attribute expressions.
2622
- if (typeof newEl.render === 'function' && !Globals$1.rendered.has(newEl))
2623
- newEl.render(attribs, true);
2624
-
2625
- // 2g. Update attribute paths to use the new element and re-apply them.
2626
- for (let i=0, attribPath; attribPath = this.attribPaths[i]; i++) {
2627
- attribPath.parentNg = this.parentNg;
2628
- attribPath.nodeMarker = newEl;
2629
- attribPath.apply(exprs[i]);
3552
+ // 2e. Swap it to the DOM.
3553
+ el.replaceWith(newEl);
2630
3554
  }
2631
3555
 
2632
- // 2e. Swap it to the DOM.
2633
- el.replaceWith(newEl);
2634
- }
2635
-
2636
- // 2f. Render
2637
- else if (typeof el.render === 'function')
2638
- el.render(attribs, changed);
3556
+ // 2f. Render
3557
+ else if (typeof el.render === 'function')
3558
+ el.render(attribs, changed);
2639
3559
 
2640
- Globals$1.currentSlotChildren = null;
3560
+ }
3561
+ finally {
3562
+ Globals$1.currentSlotChildren = prevSlotChildren;
3563
+ }
2641
3564
  }
2642
3565
 
2643
3566
  /**
@@ -2645,13 +3568,10 @@ class PathToComponent extends Path {
2645
3568
  * @param pathOffset {int}
2646
3569
  * @return {Path} */
2647
3570
  clone(newRoot, pathOffset=0) {
2648
-
2649
- let nodeMarker = this.getNewNodeMarker(newRoot, pathOffset);
2650
- let result = new PathToComponent(null, nodeMarker);
3571
+ // A component path's nodeBefore is always null (the constructor discards it), so the
3572
+ // base clone() resolves only the nodeMarker and hands back a new PathToComponent.
3573
+ let result = super.clone(newRoot, pathOffset);
2651
3574
  result.attribPaths = this.attribPaths.map(path => path.clone(newRoot, pathOffset));
2652
-
2653
-
2654
-
2655
3575
  return result;
2656
3576
  }
2657
3577
 
@@ -2671,7 +3591,7 @@ class Shell {
2671
3591
 
2672
3592
  /**
2673
3593
  * @type {DocumentFragment|Text} DOM parent of the shell's nodes. */
2674
- fragment;
3594
+ docFrag;
2675
3595
 
2676
3596
  /** @type {Path[]} Paths to where expressions should go. */
2677
3597
  paths = [];
@@ -2691,10 +3611,22 @@ class Shell {
2691
3611
  /** @type {boolean} True if any of this Shell's own paths is a PathToComponent. */
2692
3612
  hasComponentPaths = false;
2693
3613
 
3614
+ /** @type {boolean} True if any path binds an attribute that's a live HTML property
3615
+ * (checked, value, selected — Util.isHtmlProp). Users flip those underneath the template,
3616
+ * so "expression unchanged" doesn't mean "DOM unchanged" and the skip shortcuts exempt them. */
3617
+ hasLivePropPaths = false;
3618
+
2694
3619
  /** @type {boolean} True if every path consumes exactly one expression and none are components.
2695
3620
  * Lets NodeGroup.applyExprs() use a fast loop without allocating per-path expression arrays. */
2696
3621
  pathsSingleExpr = false;
2697
3622
 
3623
+ /** @type {boolean} True when a NodeGroup whose values are unchanged still has work to do:
3624
+ * components re-render so changes deeper in the tree surface, and live HTML properties are
3625
+ * rewritten because a click can flip them underneath the cached expression. The list scans
3626
+ * check this before calling PathToNodes.refreshSameItem(), so the overwhelmingly common
3627
+ * unchanged row costs one field read instead of a call. */
3628
+ needsRefresh = false;
3629
+
2698
3630
  /** @type {boolean} True if this Shell has any ids, styles, or scripts. */
2699
3631
  hasEmbeds = false;
2700
3632
 
@@ -2710,6 +3642,52 @@ class Shell {
2710
3642
  * with no per-instance Path objects. See the stampPaths setup in the constructor. */
2711
3643
  stampable = false;
2712
3644
 
3645
+ // The remaining fields are only filled in for some shells (resolve program, stampable),
3646
+ // but they're all declared here so every Shell instance shares one hidden class.
3647
+ // NodeGroup's per-row code (its constructor, applyStamp, resolveStampSlots) reads these
3648
+ // off whichever shell it's given, and a single shape keeps those loads monomorphic.
3649
+
3650
+ /** @type {?string} The Template close key, cached here by the NodeGroup constructor so
3651
+ * each new template row skips a WeakMap lookup. See Template.getCloseKey(). */
3652
+ closeKey;
3653
+
3654
+ /** @type {?int[]} The resolve program: flat [parentSlot, childIndex] pairs in dependency
3655
+ * order; pair i fills slot i+1, slot 0 being the fragment. Built by buildResolveProgram();
3656
+ * undefined for shells with components. */
3657
+ resolveOps;
3658
+
3659
+ /** @type {?Node[]} Reusable scratch array for resolved nodes; safe because resolution
3660
+ * never re-enters. */
3661
+ resolveSlots;
3662
+
3663
+ // The stamp program, set only when stampable is true:
3664
+
3665
+ /** @type {?int[]} Indexes of PathToNodes paths, checked for primitive exprs before stamping. */
3666
+ nodesPathIdx;
3667
+
3668
+ /** @type {?Path[]} One shared stamper per path; nodeMarker/parentNg are set per use. */
3669
+ stampPaths;
3670
+
3671
+ /** @type {?Uint8Array} Opcode per path; see the stamp-program comment in the constructor. */
3672
+ stampOp;
3673
+
3674
+ /** @type {?Uint16Array} paths[i].markerSlot, in a flat array so the hot loop
3675
+ * doesn't load the Path object to find its slot. */
3676
+ stampSlot;
3677
+
3678
+ /** @type {?Path[]} Per-path extra the stamp program needs: the event stamper for op 3
3679
+ * (it carries delegatedKey and eventName), the attribute name for op 4, null otherwise. */
3680
+ stampAux;
3681
+
3682
+ /** @type {?string[]} The delegatable event names this shell binds, so a loop can register
3683
+ * their dispatchers once for the whole run of rows instead of testing every bound node. */
3684
+ stampEventNames;
3685
+
3686
+ /** @type {?Uint8Array} Per-path flags the in-place rewrite loop needs, so it reads one byte
3687
+ * from a flat array instead of two properties from a Path object it otherwise wouldn't
3688
+ * touch. Bit 1 = the path binds a live HTML property, bit 2 = it's a whole-parent child. */
3689
+ stampFlags;
3690
+
2713
3691
  /**
2714
3692
  * Create the nodes but without filling in the expressions.
2715
3693
  * This is useful because the expression-less nodes created by a template can be cached.
@@ -2723,7 +3701,7 @@ class Shell {
2723
3701
 
2724
3702
  // If no html tags or entities, just create a text node.
2725
3703
  if (html.length === 1 && !html[0].match(/[<&]/)) {
2726
- this.fragment = Globals$1.doc.createTextNode(html[0]);
3704
+ this.docFrag = Globals$1.doc.createTextNode(html[0]);
2727
3705
  return;
2728
3706
  }
2729
3707
 
@@ -2741,29 +3719,32 @@ class Shell {
2741
3719
  let frag = Globals$1.doc.createDocumentFragment();
2742
3720
  while (svgEl.firstChild)
2743
3721
  frag.append(svgEl.firstChild);
2744
- this.fragment = frag;
3722
+ this.docFrag = frag;
2745
3723
  }
2746
3724
  else {
2747
3725
  template.innerHTML = htmlWithPlaceholders;
2748
- this.fragment = template.content;
3726
+ this.docFrag = template.content;
2749
3727
  }
2750
3728
  }
2751
3729
  else { // Create one text node, so shell isn't empty and NodeGroups created from it have something to point the startNode and endNode at.
2752
3730
  template.content.append(Globals$1.doc.createTextNode(''));
2753
- this.fragment = template.content;
3731
+ this.docFrag = template.content;
2754
3732
  }
2755
3733
 
2756
3734
  // 1b. Remove whitespace-only text nodes inside table-structure elements.
2757
3735
  // The parser foster-parents non-whitespace text out of tables, and whitespace-only
2758
3736
  // text between cells/rows is never rendered, so removing it is invisible.
2759
3737
  // Smaller fragments make cloning, path resolution, and insertion faster.
2760
- stripTableWhitespace(this.fragment);
3738
+ stripTableWhitespace(this.docFrag);
3739
+
3740
+ // 1c. Neutralize `is` so the browser can't upgrade a placeholder out from under us.
3741
+ renameIsAttribs(this.docFrag);
2761
3742
 
2762
3743
  // 2. Find placeholders
2763
3744
  let node;
2764
3745
  let toRemove = [];
2765
3746
  let placeholdersUsed = 0;
2766
- const walker = Globals$1.doc.createTreeWalker(this.fragment, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT);
3747
+ const walker = Globals$1.doc.createTreeWalker(this.docFrag, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_COMMENT | NodeFilter.SHOW_TEXT);
2767
3748
  while (node = walker.nextNode()) {
2768
3749
 
2769
3750
  // Remove previous elements after each iteration, so paths will still be calculated correctly.
@@ -2772,7 +3753,7 @@ class Shell {
2772
3753
 
2773
3754
  // Replace attributes
2774
3755
  if (node.nodeType === 1) {
2775
- const hasIs = node.hasAttribute('is');
3756
+ const hasIs = node.hasAttribute('_is'); // Renamed from `is` in step 1c.
2776
3757
  const isComponent = (hasIs || node.tagName.includes('-'));
2777
3758
  const componentAttribPaths = [];
2778
3759
 
@@ -2781,13 +3762,20 @@ class Shell {
2781
3762
  // The reserved key attribute identifies this template within a keyed list.
2782
3763
  // It's consumed here and never written to the DOM or passed to components.
2783
3764
  if (attr.name === 'key') {
3765
+
3766
+ // These three are template-authoring mistakes, and every one of them fails SILENTLY if
3767
+ // it isn't caught: the reconciler would key rows on a garbage value and reuse the wrong
3768
+ // DOM, with nothing reported. So they ship, unlike the assertions elsewhere in this
3769
+ // file. The cost is one regex split per unique template \u2014 never per render, never per
3770
+ // row \u2014 which is why they are affordable to keep.
2784
3771
  let parts = attr.value.split(/[\ue000-\uf8ff]/g);
2785
3772
  if (parts.length !== 2 || parts[0] !== '' || parts[1] !== '')
2786
- throw new Error(`The key attribute is reserved and must be a single expression: key=\${...}`);
2787
- if (node.parentNode !== this.fragment)
2788
- throw new Error(`The key attribute must be on a top-level element of its template.`);
3773
+ throw new Error(`Solarite: key must be one whole expression.`);
3774
+ if (node.parentNode !== this.docFrag)
3775
+ throw new Error(`Solarite: key must be on a top-level element.`);
2789
3776
  if (this.keyIndex >= 0)
2790
- throw new Error(`A template can have only one key attribute.`);
3777
+ throw new Error(`Solarite: duplicate key attribute.`);
3778
+
2791
3779
  this.keyIndex = attr.value.charCodeAt(0) - attribPlaceholder;
2792
3780
 
2793
3781
  let path = new PathToKey(null, node);
@@ -2832,19 +3820,30 @@ class Shell {
2832
3820
  }
2833
3821
 
2834
3822
  placeholdersUsed += parts.length - 1;
2835
- // In svgMode, setting typed SVG attributes (viewBox, r, etc.) with the placeholders
2836
- // stripped out makes the browser log parse errors, both here and when the fragment is cloned.
2837
- // Remove the attribute instead; apply() recreates it with the real values.
2838
- // Event attributes bound to a single expression are removed because they bind via
2839
- // addEventListener; leaving an empty onclick="" attribute violates a strict CSP when the event fires.
2840
- if (svgMode || (isEvent && !nonEmptyParts))
3823
+ // An attribute whose whole value is one expression is removed from the shell:
3824
+ // its stamped value is always the empty string, so every clone would carry a
3825
+ // useless empty attribute that costs storage on creation and a slot in the
3826
+ // element's attribute list forever, and apply() writes the real value anyway
3827
+ // (a missing attribute reads back as '', so an empty expression still writes
3828
+ // nothing). Event attributes must be removed for the same reason plus a
3829
+ // stricter one: an empty onclick="" violates a strict CSP when the event fires.
3830
+ // In svgMode, setting typed SVG attributes (viewBox, r, etc.) with the
3831
+ // placeholders stripped out makes the browser log parse errors, both here and
3832
+ // when the fragment is cloned, so those are removed whether or not they're whole.
3833
+ if (svgMode || !nonEmptyParts)
2841
3834
  node.removeAttribute(attr.name);
2842
- else try {
3835
+
3836
+ // setAttribute throws only when the template author wrote a name the browser
3837
+ // refuses, such as one holding a space or a quote. That name comes from a tagged
3838
+ // template literal's static text, so it is a typo that surfaces the first time the
3839
+ // template renders and can never appear later or for only some users. Development
3840
+ // therefore wraps the call to rethrow with the attribute name and the tag included,
3841
+ // because the browser's own DOMException names neither and leaves the author
3842
+ // hunting. Production ships the bare call and lets that DOMException through: the
3843
+ // friendlier wording is only worth its bytes to whoever can still fix the template.
3844
+ else
2843
3845
  node.setAttribute(attr.name, parts.join(''));
2844
- }
2845
- catch (e) {
2846
- throw new Error(`Error setting attribute "${attr.name}" on node <${node.tagName}>: ${e.message}`);
2847
- }
3846
+
2848
3847
  }
2849
3848
  }
2850
3849
  }
@@ -2855,10 +3854,6 @@ class Shell {
2855
3854
  path.attribPaths = componentAttribPaths;
2856
3855
  this.paths.splice(this.paths.length - componentAttribPaths.length, 0, path); // Insert before its componentAttribPaths
2857
3856
 
2858
- if (hasIs) {
2859
- node.setAttribute('_is', node.getAttribute('is'));
2860
- node.removeAttribute('is');
2861
- }
2862
3857
  }
2863
3858
  }
2864
3859
 
@@ -2866,7 +3861,7 @@ class Shell {
2866
3861
  else if (node.nodeType === 8 && node.nodeValue === '!✨!') {
2867
3862
 
2868
3863
  if (node?.parentNode?.closest && node?.parentNode?.closest('[contenteditable]'))
2869
- throw new Error(`Contenteditable can't have expressions inside them. Use <div contenteditable value="\${...}"> instead.`);
3864
+ throw new Error(`Solarite: no \${...} inside contenteditable; use value="\${...}".`);
2870
3865
 
2871
3866
  let parent = node.parentNode;
2872
3867
 
@@ -2875,7 +3870,7 @@ class Shell {
2875
3870
  // Components and slots are excluded because they move their children
2876
3871
  // during instantiation, which would orphan the expression's region.
2877
3872
  if (parent.nodeType === 1 && !node.previousSibling && !node.nextSibling
2878
- && !parent.tagName.includes('-') && parent.tagName !== 'SLOT' && !parent.hasAttribute('is')) {
3873
+ && !parent.tagName.includes('-') && parent.tagName !== 'SLOT' && !parent.hasAttribute('_is')) {
2879
3874
  let path = new PathToNodes(null, parent);
2880
3875
  path.wholeParent = true;
2881
3876
  this.paths.push(path);
@@ -2913,11 +3908,6 @@ class Shell {
2913
3908
  }
2914
3909
  }
2915
3910
 
2916
- // Comments become text nodes when inside textareas.
2917
- else if (node.nodeType === 3 && node.parentNode?.tagName === 'TEXTAREA' && node.textContent.includes('<!--!✨!-->'))
2918
- throw new Error(`Textarea can't have expressions inside them. Use <textarea value="\${...}"> instead.`);
2919
-
2920
-
2921
3911
  // Sometimes users will comment out a block of html code that has expressions.
2922
3912
  // Here we look for expressions in comments.
2923
3913
  // We don't actually update them dynamically, but we still add paths for them.
@@ -2931,29 +3921,39 @@ class Shell {
2931
3921
  }
2932
3922
  }
2933
3923
 
2934
- // Replace comment placeholders inside script and style tags, which have become text nodes.
2935
- else if (node.nodeType === 3 && ['SCRIPT', 'STYLE'].includes(node.parentNode?.nodeName)) { // Node.TEXT_NODE
2936
- let parts = node.textContent.split(commentPlaceholder);
2937
- if (parts.length > 1) {
2938
-
2939
- let placeholders = [];
2940
- for (let i = 0; i<parts.length; i++) {
2941
- let current = Globals$1.doc.createTextNode(parts[i]);
2942
- node.parentNode.insertBefore(current, node);
2943
- if (i > 0)
2944
- placeholders.push(current);
2945
- }
2946
-
2947
- for (let i=0, node; node=placeholders[i]; i++) {
2948
- let path = new PathToNodes(node.previousSibling, node);
2949
- this.paths.push(path);
2950
- placeholdersUsed ++;
3924
+ // A few elements have raw-text bodies, which the html parser reads as literal characters
3925
+ // rather than as markup. A comment placeholder written inside one therefore never becomes
3926
+ // a comment node; it arrives here as ordinary text. A textarea can't support expressions
3927
+ // in its body at all, while script and style can, by splitting their text around each
3928
+ // placeholder so that every expression gets a text node of its own to write into.
3929
+ else if (node.nodeType === 3) { // Node.TEXT_NODE
3930
+ let parentName = node.parentNode?.nodeName;
3931
+
3932
+ if (parentName === 'TEXTAREA' && node.textContent.includes(commentPlaceholder))
3933
+ throw new Error(`Solarite: no \${...} inside textarea; use value="\${...}".`);
3934
+
3935
+ else if (parentName === 'SCRIPT' || parentName === 'STYLE') {
3936
+ let parts = node.textContent.split(commentPlaceholder);
3937
+ if (parts.length > 1) {
3938
+
3939
+ // Every part is inserted before the original node, in order, so from the second
3940
+ // part onward the text node made on the previous iteration is already sitting
3941
+ // immediately before this one and serves as the new path's nodeBefore.
3942
+ for (let i = 0; i<parts.length; i++) {
3943
+ let current = Globals$1.doc.createTextNode(parts[i]);
3944
+ node.parentNode.insertBefore(current, node);
3945
+ if (i > 0) {
3946
+ let path = new PathToNodes(current.previousSibling, current);
3947
+ this.paths.push(path);
3948
+ placeholdersUsed ++;
3949
+
3950
+
3951
+ }
3952
+ }
2951
3953
 
2952
-
3954
+ // Removing it here will mess up the treeWalker.
3955
+ toRemove.push(node);
2953
3956
  }
2954
-
2955
- // Removing them here will mess up the treeWalker.
2956
- toRemove.push(node);
2957
3957
  }
2958
3958
  }
2959
3959
  }
@@ -2962,31 +3962,37 @@ class Shell {
2962
3962
  // Less than or equal because there can be one path to multiple expressions
2963
3963
  // if those expressions are in the same attribute value.
2964
3964
  if (placeholdersUsed !== html.length-1)
2965
- throw new Error(`Could not parse expressions in template. Check for duplicate attributes or malformed html: ${html.join('${...}')}`);
3965
+ throw new Error(`Solarite: bad html or duplicate attribute: ${html.join('${...}')}`);
2966
3966
 
2967
3967
  for (let path of this.paths) {
2968
- if (path.nodeBefore)
2969
- path.nodeBeforeIndex = Array.prototype.indexOf.call(path.nodeBefore.parentNode.childNodes, path.nodeBefore);
3968
+ // -1 when the path has no nodeBefore. Assigned unconditionally so every shell path
3969
+ // of a given class takes the same property-addition order and shares one hidden class.
3970
+ path.nodeBeforeIndex = path.nodeBefore
3971
+ ? Array.prototype.indexOf.call(path.nodeBefore.parentNode.childNodes, path.nodeBefore)
3972
+ : -1;
2970
3973
 
2971
3974
  // Must be calculated after we remove the toRemove nodes:
2972
3975
  path.nodeMarkerPath = Path.get(path.nodeMarker);
2973
-
2974
-
2975
3976
  }
2976
3977
 
2977
3978
  this.findEmbeds();
2978
- this.buildResolveProgram();
2979
3979
 
3980
+ // This scan must run before buildResolveProgram(), which skips shells with components
3981
+ // and reads hasComponentPaths rather than walking the paths a second time.
2980
3982
  this.pathsSingleExpr = true;
2981
3983
  for (let path of this.paths) {
2982
3984
  if (path instanceof PathToComponent) {
2983
3985
  this.hasComponentPaths = true;
2984
3986
  this.pathsSingleExpr = false;
2985
- break; // Both facts are now decided.
2986
3987
  }
2987
- if (path.getExpressionCount() !== 1)
2988
- this.pathsSingleExpr = false; // Keep scanning for components.
3988
+ else if (path.getExpressionCount() !== 1)
3989
+ this.pathsSingleExpr = false;
3990
+ if (path.isHtmlProperty) // needs the full scan — no early break
3991
+ this.hasLivePropPaths = true;
2989
3992
  }
3993
+ this.needsRefresh = this.hasComponentPaths || (this.hasLivePropPaths && this.pathsSingleExpr);
3994
+
3995
+ this.buildResolveProgram();
2990
3996
 
2991
3997
  // Stampable shells create NodeGroups without allocating any Path objects:
2992
3998
  // NodeGroup.applyStamp() writes expressions through these shared stamper paths,
@@ -3011,13 +4017,46 @@ class Shell {
3011
4017
  }
3012
4018
  if (ok) {
3013
4019
  this.stampable = true;
3014
-
3015
- /** @type {int[]} Indexes of PathToNodes paths, checked for primitive exprs before stamping. */
3016
4020
  this.nodesPathIdx = nodesIdx;
3017
-
3018
- /** @type {Path[]} One shared stamper per path; nodeMarker/parentNg are set per use. */
3019
4021
  this.stampPaths = this.paths.map(p => p.cloneWithNodes(null, p.nodeMarker));
3020
4022
 
4023
+ // Compiled stamp program: one opcode per path lets applyStamp() write a fresh
4024
+ // row through a flat branch chain instead of dispatching applySingle() per path.
4025
+ // 0 = generic (shared stamper fallback), 1 = list key (no DOM), 2 = wholeParent
4026
+ // child text, 3 = delegatable single-expression event (written as node expandos
4027
+ // when the root delegates, the default).
4028
+ let n = this.paths.length;
4029
+ this.stampOp = new Uint8Array(n);
4030
+ this.stampSlot = new Uint16Array(n);
4031
+ this.stampAux = new Array(n).fill(null);
4032
+ this.stampFlags = new Uint8Array(n);
4033
+
4034
+ let eventNames = null;
4035
+ for (let i=0; i<n; i++) {
4036
+ let p = this.paths[i], sp = this.stampPaths[i];
4037
+ this.stampSlot[i] = p.markerSlot;
4038
+ this.stampFlags[i] = (sp.isHtmlProperty ? 1 : 0) | (sp.wholeParent ? 2 : 0);
4039
+ if (p instanceof PathToKey)
4040
+ this.stampOp[i] = 1;
4041
+ else if (sp.wholeParent)
4042
+ this.stampOp[i] = 2;
4043
+ else if (sp instanceof PathToEvent && sp.delegatedKey !== undefined && !sp.attrValue) {
4044
+ this.stampOp[i] = 3;
4045
+ this.stampAux[i] = sp;
4046
+ (eventNames ??= []).push(sp.eventName);
4047
+ }
4048
+
4049
+ // A plain attribute holding one whole expression. The shell no longer carries
4050
+ // the attribute at all (see the placeholder handling above), so on a freshly
4051
+ // cloned row the value is known to be absent and a string can be written
4052
+ // without first reading back what's there.
4053
+ else if (sp instanceof PathToAttribValue && !sp.attrValue && !sp.isHtmlProperty
4054
+ && !sp.isComponentAttrib) {
4055
+ this.stampOp[i] = 4;
4056
+ this.stampAux[i] = sp.attribName;
4057
+ }
4058
+ }
4059
+ this.stampEventNames = eventNames;
3021
4060
  }
3022
4061
  }
3023
4062
 
@@ -3032,42 +4071,64 @@ class Shell {
3032
4071
  * @param htmlChunks {string[]}
3033
4072
  * @returns {string} Html with the placeholders in place. */
3034
4073
  static addPlaceholders(htmlChunks) {
3035
- let result = [];
4074
+ let result = '';
4075
+
4076
+ // Where the tokenizer is as it walks the chunks. An expression can sit in the middle of an attribute
4077
+ // value, so both of these have to survive from one chunk to the next. Nothing else has to: an
4078
+ // expression anywhere inside a tag gets the same attribute placeholder, so the machine only has to
4079
+ // know whether it is inside a tag at all, and whether a quoted value is currently open.
4080
+ let inTag = false; // True from the '<' that opens a tag or comment through the '>' that closes it.
4081
+ let quote = null; // The quote character that opened the attribute value we're inside of: null, '"', or "'".
3036
4082
 
3037
- let htmlParser = new HtmlParser(); // Reset the context.
3038
4083
  for (let i = 0; i < htmlChunks.length; i++) {
3039
- let lastHtml = htmlChunks[i];
4084
+ let html = htmlChunks[i];
3040
4085
 
3041
4086
  // Append -solarite-placholder to web component tags, so we can pass args to them when they're instantiated.
3042
- let lastIndex = 0;
3043
- let context = htmlParser.parse(lastHtml, (html, index, prevContext/*, nextContext*/) => { // This function is called every time the html context changes.
3044
- if (lastIndex !== index) {
3045
- let token = html.slice(lastIndex, index);
3046
-
3047
- if (prevContext === HtmlParser.Tag) {
3048
- // Find Web Component tags and append -solarite-placeholder to their tag names
3049
- // This way we can gather their constructor arguments and their children before we call their constructor.
3050
- // Later, PathToComponent.apply() will replace them with the real components.
3051
- // Ctrl+F "solarite-placeholder" in project to find all code that manages subcomponents.
3052
- const isWebComponentTagName = /^<\/?[a-z][a-z0-9]*-[a-z0-9-]+/i; // a dash in the middle
3053
- token = token.replace(isWebComponentTagName, match => match + '-SOLARITE-PLACEHOLDER'); // caps to match other instances of this string, for better compression.
4087
+ let lastIndex = 0; // Start of the run of this chunk not yet copied into result.
4088
+ for (let j = 0; j < html.length; j++) {
4089
+ const char = html[j];
4090
+
4091
+ if (!inTag) {
4092
+ if (char === '<' && html[j + 1].match(/[/a-z!]/i)) { // Start of a tag or comment.
4093
+ inTag = true;
4094
+
4095
+ // A component suffix can only ever be added right here, at the '<' that opens the tag, so
4096
+ // the name is matched on the spot with a sticky regex rather than collected into a buffer
4097
+ // and matched later. The greedy tag-name class can't run past the name, because every
4098
+ // character that can follow a tag name is outside it.
4099
+ isWebComponentTagName.lastIndex = j;
4100
+ let match = isWebComponentTagName.exec(html);
4101
+ if (match) {
4102
+ let end = j + match[0].length;
4103
+ result += html.slice(lastIndex, end) + '-SOLARITE-PLACEHOLDER';
4104
+ lastIndex = end;
4105
+ }
3054
4106
  }
4107
+ }
3055
4108
 
3056
- result.push(token);
4109
+ // Inside a tag, only two characters end anything: the quote that closes the value we're in, or,
4110
+ // when we're not in one, the '>' that closes the tag. Attribute names, '=', unquoted values and
4111
+ // whitespace all need no handling at all.
4112
+ else if (quote) {
4113
+ if (char === quote)
4114
+ quote = null;
3057
4115
  }
3058
- lastIndex = index;
3059
- });
4116
+ else if (char === '"' || char === "'")
4117
+ quote = char;
4118
+ else if (char === '>')
4119
+ inTag = false;
4120
+ }
4121
+
4122
+ result += html.slice(lastIndex);
3060
4123
 
3061
4124
  // Insert placeholders
3062
- if (i < htmlChunks.length - 1) {
3063
- if (context === HtmlParser.Text)
3064
- result.push(commentPlaceholder); // Comment Placeholder. because we can't put text in between <tr> tags for example.
3065
- else
3066
- result.push(String.fromCharCode(attribPlaceholder + i));
3067
- }
4125
+ if (i < htmlChunks.length - 1)
4126
+ result += inTag
4127
+ ? String.fromCharCode(attribPlaceholder + i)
4128
+ : commentPlaceholder; // Comment Placeholder. because we can't put text in between <tr> tags for example.
3068
4129
  }
3069
4130
 
3070
- return result.join('');
4131
+ return result;
3071
4132
  }
3072
4133
 
3073
4134
  /**
@@ -3079,21 +4140,18 @@ class Shell {
3079
4140
  * this.ids
3080
4141
  * this.staticComponents */
3081
4142
  findEmbeds() {
3082
- this.scripts = Array.prototype.map.call(this.fragment.querySelectorAll('script'), el => Path.get(el));
4143
+ this.scripts = Array.prototype.map.call(this.docFrag.querySelectorAll('script'), el => Path.get(el));
3083
4144
 
3084
4145
  // TODO: only find styles that have Paths in them?
3085
- this.styles = Array.prototype.map.call(this.fragment.querySelectorAll('style'), el => Path.get(el));
4146
+ this.styles = Array.prototype.map.call(this.docFrag.querySelectorAll('style'), el => Path.get(el));
3086
4147
 
3087
- let idEls = this.fragment.querySelectorAll('[id],[data-id]');
3088
-
3089
- // Check for valid id names.
3090
- for (let el of idEls) {
3091
- let id = el.getAttribute('data-id') || el.getAttribute('id');
3092
- if (Globals$1.div.hasOwnProperty(id))
3093
- throw new Error(`<${el.tagName.toLowerCase()} id="${id}"> can't override existing HTMLElement id property.`)
3094
- }
3095
-
3096
- this.ids = Array.prototype.map.call(idEls, el => Path.get(el));
4148
+ // An id that would clobber a built-in element property is reported by Util.bindId(), which
4149
+ // asks the real component object, with `in`, at the moment the binding happens. The check
4150
+ // that used to stand here asked Globals.div.hasOwnProperty(id) instead, and a freshly
4151
+ // created element has no own properties at all — every DOM property an element exposes
4152
+ // lives on its interface prototype — so that test could never be true and the error it
4153
+ // guarded was never reachable.
4154
+ this.ids = Array.prototype.map.call(this.docFrag.querySelectorAll('[id],[data-id]'), el => Path.get(el));
3097
4155
 
3098
4156
  this.hasEmbeds = this.ids.length > 0 || this.styles.length > 0 || this.scripts.length > 0;
3099
4157
  }
@@ -3104,25 +4162,37 @@ class Shell {
3104
4162
  * Replaces per-path root-to-node walks in the hot NodeGroup creation path.
3105
4163
  * Skipped for shells with components, whose clone() has special attribPaths behavior. */
3106
4164
  buildResolveProgram() {
3107
- let hasComponents = false;
3108
- for (let path of this.paths)
3109
- if (path instanceof PathToComponent) {
3110
- hasComponents = true;
3111
- break;
3112
- }
3113
- if (hasComponents || !this.paths.length)
4165
+ if (this.hasComponentPaths || !this.paths.length)
3114
4166
  return;
3115
4167
 
3116
4168
  let ops = [];
3117
4169
  let slotOf = new Map();
3118
- let frag = this.fragment;
4170
+ let frag = this.docFrag;
3119
4171
  let nextSlot = 1;
3120
4172
  let getSlot = node => {
3121
4173
  if (node === frag)
3122
4174
  return 0;
3123
4175
  let s = slotOf.get(node);
3124
4176
  if (s === undefined) {
3125
- ops.push(getSlot(node.parentNode), Array.prototype.indexOf.call(node.parentNode.childNodes, node));
4177
+ // Two ways to reach a node, costing one pointer step each: walk forward from an
4178
+ // already-resolved earlier sibling, or take the parent's firstChild and walk
4179
+ // forward. Sibling steps win whenever they're no more numerous, and they can
4180
+ // also spare the parent a slot of its own — in a row of cells, resolving each
4181
+ // <td> from the previous one is one step instead of firstChild plus its index.
4182
+ let d = 0, from = -1;
4183
+ for (let sib = node.previousSibling; sib; sib = sib.previousSibling) {
4184
+ d++;
4185
+ let ss = slotOf.get(sib);
4186
+ if (ss !== undefined) {
4187
+ from = ss;
4188
+ break;
4189
+ }
4190
+ }
4191
+ let index = Array.prototype.indexOf.call(node.parentNode.childNodes, node);
4192
+ if (from >= 0 && d <= index + 1)
4193
+ ops.push(from, -d); // A negative step count means "walk nextSibling from that slot".
4194
+ else
4195
+ ops.push(getSlot(node.parentNode), index);
3126
4196
  s = nextSlot++;
3127
4197
  slotOf.set(node, s);
3128
4198
  }
@@ -3133,10 +4203,7 @@ class Shell {
3133
4203
  path.beforeSlot = path.nodeBefore ? getSlot(path.nodeBefore) : -1;
3134
4204
  }
3135
4205
 
3136
- /** @type {?int[]} Flat [parentSlot, childIndex] pairs; pair i fills slot i+1. */
3137
4206
  this.resolveOps = ops;
3138
-
3139
- /** @type {Node[]} Reusable scratch array for resolved nodes; safe because resolution never re-enters. */
3140
4207
  this.resolveSlots = new Array(nextSlot);
3141
4208
 
3142
4209
  // A lone root element means slot 1 is always that element (the first op pair is [0, 0]),
@@ -3181,6 +4248,15 @@ class Shell {
3181
4248
 
3182
4249
  const commentPlaceholder = `<!--!✨!-->`;
3183
4250
 
4251
+ // A tag name with a dash in the middle, which is what makes an element a web component. addPlaceholders()
4252
+ // tests this at each '<' that opens a tag, and a match gets -solarite-placeholder appended to its tag name.
4253
+ // That way we can gather a component's constructor arguments and its children before we call its constructor;
4254
+ // later PathToComponent.applyAll() replaces the placeholder tag with the real component. The suffix is written in
4255
+ // caps wherever it appears, so that the several copies of it in this project compress well. It's sticky rather
4256
+ // than anchored so it can be tested at an offset within the chunk instead of against a sliced-out token.
4257
+ // Ctrl+F "solarite-placeholder" in project to find all code that manages subcomponents.
4258
+ const isWebComponentTagName = /<\/?[a-z][a-z0-9]*-[a-z0-9-]+/iy;
4259
+
3184
4260
  // Elements whose whitespace-only text children are never rendered.
3185
4261
  const tableTags = ['TABLE', 'THEAD', 'TBODY', 'TFOOT', 'TR'];
3186
4262
 
@@ -3200,6 +4276,38 @@ function stripTableWhitespace(el) {
3200
4276
  }
3201
4277
  }
3202
4278
 
4279
+ /**
4280
+ * Rename every `is` attribute to `_is`, rebuilding the element to do it.
4281
+ *
4282
+ * A component written as a dashed tag is neutralized in the shell by renaming the TAG
4283
+ * (`<my-tag>` becomes `<my-tag-SOLARITE-PLACEHOLDER>`), so the browser never recognizes the
4284
+ * placeholder and never upgrades it. A customized built-in cannot be neutralized that way,
4285
+ * because its tag has to stay real: a `<tr is="my-row">` that is not a `<tr>` is thrown out
4286
+ * by the parser's table rules. So its ATTRIBUTE is renamed instead.
4287
+ *
4288
+ * Renaming the attribute in place is not enough. `is` is also recorded in an internal slot on
4289
+ * the element, which removeAttribute() cannot clear and cloneNode() copies, so a placeholder
4290
+ * that was parsed with `is` stays a customized built-in as far as the browser is concerned.
4291
+ * Every clone of it is upgraded the moment it enters a document with a browsing context —
4292
+ * running the component's constructor on the placeholder, before PathToComponent has
4293
+ * instantiated the real element or evaluated the attribute expressions meant for it. A
4294
+ * constructor that renders then renders the placeholder, whose children are the ones the user
4295
+ * declared, and those get handed to the real instance as if they were slot content.
4296
+ *
4297
+ * Building a fresh element and moving everything across is the only way to drop that slot.
4298
+ * It happens once per unique template, because Shells are cached, and never per render.
4299
+ *
4300
+ * @param docFrag {DocumentFragment} */
4301
+ function renameIsAttribs(docFrag) {
4302
+ for (let el of docFrag.querySelectorAll('[is]')) {
4303
+ let clean = el.ownerDocument.createElement(el.tagName);
4304
+ for (let attrib of el.attributes)
4305
+ clean.setAttribute(attrib.name === 'is' ? '_is' : attrib.name, attrib.value);
4306
+ clean.append(...el.childNodes);
4307
+ el.replaceWith(clean);
4308
+ }
4309
+ }
4310
+
3203
4311
  // One-entry memo for Shell.get().
3204
4312
  let lastHtmlStrings = null, lastSvgMode = false, lastShell = null;
3205
4313
 
@@ -3209,6 +4317,50 @@ const attribPlaceholder = 0xe000; // https://en.wikipedia.org/wiki/Private_Use_A
3209
4317
 
3210
4318
  /** @typedef {boolean|string|number|function|Object|Array|Date|Node|Template} Expr */
3211
4319
 
4320
+ /** Stand-in Shell for text NodeGroups, which are never parsed from html. Its default field
4321
+ * values (no components, no live properties, no single-expression paths) are exactly what the
4322
+ * per-row code must see for a bare Text node, so ng.shell is never null. */
4323
+ const textShell = new Shell();
4324
+
4325
+ // The Shell whose delegated dispatchers a root last registered, kept on the RootNodeGroup so
4326
+ // that a run of rows checks one field instead of asking at every bound node. A Symbol rather
4327
+ // than a declared field, since only root NodeGroups ever carry it and a declared field would
4328
+ // cost a slot on every row. The delegation mode isn't part of it: it comes from the root's
4329
+ // render options, which are fixed when the root is created.
4330
+ const lastStampedShellKey = Symbol('solariteStampedShell');
4331
+
4332
+ /**
4333
+ * Run a Shell's precomputed resolve program (see Shell.buildResolveProgram) into the shell's
4334
+ * shared slots array, which the caller has already seeded with its starting node.
4335
+ * Each node is reached with firstChild/nextSibling pointer walks instead of childNodes[index];
4336
+ * the live NodeList indexing is markedly slower, and the indices are small (markers are
4337
+ * elements, often the first child after whitespace stripping). A negative step count means the
4338
+ * program reaches this node by walking forward from an earlier sibling's slot instead of from
4339
+ * its parent.
4340
+ * @param slots {Node[]} The shell's shared scratch array; slot 0 is the fragment.
4341
+ * @param ops {int[]} Flat [parentSlot, childIndex] pairs in dependency order.
4342
+ * @param i {int} Index of the first op pair to run; earlier pairs are pre-seeded by the caller.
4343
+ * @param s {int} Slot that pair fills.
4344
+ * @return {Node[]} slots, so callers can resolve and use it in one expression. */
4345
+ function runResolveOps(slots, ops, i, s) {
4346
+ for (; i<ops.length; i+=2, s++) {
4347
+ let k = ops[i+1], node;
4348
+ if (k < 0) {
4349
+ node = slots[ops[i]];
4350
+ do
4351
+ node = node.nextSibling;
4352
+ while (++k < 0);
4353
+ }
4354
+ else {
4355
+ node = slots[ops[i]].firstChild;
4356
+ for (; k>0; k--)
4357
+ node = node.nextSibling;
4358
+ }
4359
+ slots[s] = node;
4360
+ }
4361
+ return slots;
4362
+ }
4363
+
3212
4364
  /**
3213
4365
  * A group of Nodes instantiated from a Shell, with Expr's filled in.
3214
4366
  *
@@ -3242,11 +4394,11 @@ class NodeGroup {
3242
4394
  * matched by PathToNodes.applyKeyed(). Undefined for unkeyed NodeGroups. */
3243
4395
  key;
3244
4396
 
3245
- /** @type {boolean} True if any of this NodeGroup's own paths is a PathToComponent. */
3246
- hasComponentPaths = false;
3247
-
3248
- /** @type {boolean} True if every path consumes exactly one expression and none are components. */
3249
- pathsSingleExpr = false;
4397
+ /** @type {Shell} The Shell this NodeGroup was cloned from, so the per-row code can read
4398
+ * hasComponentPaths/hasLivePropPaths/pathsSingleExpr and the stamp program off it instead
4399
+ * of copying them onto every instance and re-looking the Shell up on every apply.
4400
+ * Text NodeGroups get the shared empty textShell, which reports false for all of them. */
4401
+ shell;
3250
4402
 
3251
4403
  /** @type {boolean} True until applyExprs() finishes the first time.
3252
4404
  * While true, ancestor node caches can't reference this NodeGroup's nodes, so they don't need invalidation. */
@@ -3257,6 +4409,11 @@ class NodeGroup {
3257
4409
  * @type {Node[]} Cached result of getNodes() used only for improving performance.*/
3258
4410
  nodesCache;
3259
4411
 
4412
+ /** @type {?Node[]} Slot nodes resolved by the first rewriteStamp(); a stamped group's
4413
+ * element structure never changes while it stays stampable, so they're reused on every
4414
+ * later rewrite. Declared here so every NodeGroup keeps one monomorphic hidden class. */
4415
+ stampSlotsCache = null;
4416
+
3260
4417
  /**
3261
4418
  * A map between <style> Elements and their text content.
3262
4419
  * This lets NodeGroup.updateStyles() see when the style text has changed.
@@ -3289,24 +4446,22 @@ class NodeGroup {
3289
4446
  // If it's just a text node, skip a bunch of unnecessary steps.
3290
4447
  // el can be an existing Text node to adopt, from PathToNodes' bare-text fast path.
3291
4448
  if (template.isText) {
4449
+ this.shell = textShell;
3292
4450
  this.closeKey = template.getCloseKey();
3293
4451
  this.startNode = this.endNode = el || Globals$1.doc.createTextNode(template.html[0]);
3294
4452
  }
3295
4453
 
3296
4454
  else {
3297
4455
  // Get a cached version of the parsed and instantiated html, and Paths:
3298
- const shell = Shell.get(template.html, template.svgMode);
4456
+ const shell = this.shell = Shell.get(template.html, template.svgMode);
3299
4457
 
3300
4458
  // The shell caches the close key so each new template doesn't repeat the WeakMap lookup.
3301
4459
  this.closeKey = shell.closeKey ??= template.getCloseKey();
3302
4460
 
3303
- this.hasComponentPaths = shell.hasComponentPaths;
3304
- this.pathsSingleExpr = shell.pathsSingleExpr;
3305
-
3306
4461
  // A lone root element is cloned directly, skipping a throwaway fragment wrapper.
3307
4462
  // Only for child NodeGroups; RootNodeGroup's grafting expects a fragment.
3308
4463
  if (shell.singleRoot && parentPath !== null) {
3309
- const clone = shell.fragment.firstChild.cloneNode(true);
4464
+ const clone = shell.docFrag.firstChild.cloneNode(true);
3310
4465
  this.startNode = this.endNode = clone;
3311
4466
 
3312
4467
  // Stampable shells skip path creation entirely; the first applyExprs() routes
@@ -3315,7 +4470,7 @@ class NodeGroup {
3315
4470
  this.setPathsFromFragment(clone, shell, 0, true);
3316
4471
  }
3317
4472
  else {
3318
- const shellFragment = shell.fragment.cloneNode(true);
4473
+ const shellFragment = shell.docFrag.cloneNode(true);
3319
4474
 
3320
4475
  if (shellFragment.nodeType === 11) { // DocumentFragment
3321
4476
  this.startNode = shellFragment.firstChild;
@@ -3356,8 +4511,12 @@ class NodeGroup {
3356
4511
  * Dispatches expression handling to other functions depending on the path type.
3357
4512
  * @param exprs {(*|*[]|function|Template)[]}
3358
4513
  * @param includeNonComponents {boolean} False to only apply component paths,
3359
- * used when the non-component exprs are known to be unchanged. */
3360
- applyExprs(exprs, includeNonComponents=true) {
4514
+ * used when the non-component exprs are known to be unchanged.
4515
+ * @param lastExprs {?Expr[]} The expressions applied last time, when the caller has them.
4516
+ * Paths that would provably do nothing with an unchanged expression are then skipped —
4517
+ * see Path.skipIfSame. A root template's event bindings are the usual beneficiaries:
4518
+ * they are the same handlers on every render, and re-binding them costs a call apiece. */
4519
+ applyExprs(exprs, includeNonComponents=true, lastExprs=null) {
3361
4520
 
3362
4521
 
3363
4522
 
@@ -3365,14 +4524,18 @@ class NodeGroup {
3365
4524
 
3366
4525
  // Fast path: every path consumes exactly one expression and none are components,
3367
4526
  // so skip the bookkeeping that maps expressions to paths.
3368
- if (this.pathsSingleExpr) {
4527
+ if (this.shell.pathsSingleExpr) {
3369
4528
  if (includeNonComponents) {
3370
4529
  if (paths === null) { // Created from a stampable shell; no paths yet.
3371
4530
  this.applyStamp(exprs);
3372
4531
  return;
3373
4532
  }
3374
- for (let i = paths.length - 1; i >= 0; i--)
3375
- paths[i].applySingle(exprs[i]);
4533
+ for (let i = paths.length - 1; i >= 0; i--) {
4534
+ let path = paths[i];
4535
+ if (lastExprs !== null && path.skipIfSame && lastExprs[i] === exprs[i])
4536
+ continue;
4537
+ path.applySingle(exprs[i]);
4538
+ }
3376
4539
 
3377
4540
  if (this.styles)
3378
4541
  this.updateStyles();
@@ -3399,7 +4562,7 @@ class NodeGroup {
3399
4562
  let exprIndex = exprs.length; // Update exprs at paths.
3400
4563
  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.
3401
4564
  for (let i = paths.length - 1, path; path = paths[i]; i--) {
3402
- if (i===0 && path instanceof PathToComponent && path.nodeMarker === this.getRootNode())
4565
+ if (i===0 && path instanceof PathToComponent && path.nodeMarker === this.getRootEl())
3403
4566
  continue;
3404
4567
 
3405
4568
  // Get the expressions associated with this path.
@@ -3411,10 +4574,10 @@ class NodeGroup {
3411
4574
  // They use expressions from the paths that provide their attributes.
3412
4575
  if (path instanceof PathToComponent) {
3413
4576
  let attribExprs = pathExprs.slice(i+1, i+1 + path.attribPaths.length); // +1 b/c we move forward from the component path.
3414
- path.apply(attribExprs);
4577
+ path.applyAll(attribExprs);
3415
4578
  }
3416
4579
  else if (includeNonComponents)
3417
- path.apply(pathExprs[i]);
4580
+ path.applyAll(pathExprs[i]);
3418
4581
  }
3419
4582
 
3420
4583
  // If there's leftover expressions, there's probably an issue with the Shell that created this NodeGroup,
@@ -3443,8 +4606,7 @@ class NodeGroup {
3443
4606
  * falls back to materializing real paths and applying normally.
3444
4607
  * @param exprs {Expr[]} */
3445
4608
  applyStamp(exprs) {
3446
- let template = this.template;
3447
- let shell = Shell.get(template.html, template.svgMode);
4609
+ let shell = this.shell;
3448
4610
 
3449
4611
  // 1. Bail to real paths when any child-node expression isn't a primitive.
3450
4612
  let nodesIdx = shell.nodesPathIdx;
@@ -3460,27 +4622,71 @@ class NodeGroup {
3460
4622
  }
3461
4623
  }
3462
4624
 
3463
- // 2. Resolve target nodes, then write each expression.
4625
+ // 2. Resolve target nodes, then run the shell's compiled stamp program: a flat
4626
+ // opcode per path replaces per-path applySingle() dispatch (see Shell.stampOp).
3464
4627
  let slots = this.resolveStampSlots(shell);
3465
- let paths = shell.paths, stampers = shell.stampPaths;
3466
- for (let i = paths.length - 1; i >= 0; i--) {
3467
- let stamper = stampers[i];
3468
- let marker = slots[paths[i].markerSlot];
3469
-
3470
- // A wholeParent text path's marker is the (freshly cloned, empty) only-child slot:
3471
- // write its text directly, skipping applySingle's branching and the shared-stamper
3472
- // bookkeeping. Child exprs are primitive here (step 1 bailed otherwise).
3473
- if (stamper.wholeParent) {
3474
- let v = exprs[i];
4628
+ let ops = shell.stampOp, slotIdx = shell.stampSlot, aux = shell.stampAux;
4629
+ let stampers = shell.stampPaths;
4630
+ let rootNg = this.rootNg;
4631
+ let root = rootNg.rootEl;
4632
+ // Any value other than false or an array of event names means delegate everything.
4633
+ let opt = rootNg.renderOptions?.eventDelegation;
4634
+ let delegateAll = opt !== false && !Array.isArray(opt);
4635
+
4636
+ // Register this shell's delegated dispatchers once for a whole run of rows. They live on
4637
+ // the root and the document, not on the bound nodes, so asking per node — as the general
4638
+ // binding path has to — would be a call and a set lookup for every handler in the list.
4639
+ let names = shell.stampEventNames;
4640
+ if (names !== null && delegateAll && rootNg[lastStampedShellKey] !== shell) {
4641
+ for (let k=0; k<names.length; k++)
4642
+ ensureDelegatedDispatcher(root, names[k]);
4643
+ rootNg[lastStampedShellKey] = shell;
4644
+ }
4645
+
4646
+ let firstApply = this.firstApply;
4647
+ for (let i = ops.length - 1; i >= 0; i--) {
4648
+ let v = exprs[i];
4649
+ let o = ops[i];
4650
+
4651
+ // Whole-parent child text: the marker is the (freshly cloned, empty) only-child
4652
+ // slot. Child exprs are primitive here (step 1 bailed otherwise).
4653
+ if (o === 2) {
3475
4654
  if (typeof v === 'number')
3476
4655
  v += '';
3477
- marker.textContent = v;
3478
- continue;
4656
+ slots[slotIdx[i]].textContent = v;
4657
+ }
4658
+
4659
+ // Delegatable event with a valid handler shape: write the node expandos
4660
+ // directly, mirroring bindEvent()'s delegated branch. An event-name-array
4661
+ // delegation option or an invalid value falls through to the generic stamper.
4662
+ else if (o === 3 && delegateAll
4663
+ && (typeof v === 'function' || (Array.isArray(v) && typeof v[0] === 'function'))) {
4664
+ let sp = aux[i];
4665
+ let node = slots[slotIdx[i]];
4666
+ node[sp.delegatedKey] = v;
4667
+ node[delegatedRootKey] = root;
4668
+ }
4669
+
4670
+ // A plain attribute on a freshly cloned row: the shell left it off, so an empty
4671
+ // value means there is simply nothing to write, and any other string can go
4672
+ // straight in without reading the attribute back first.
4673
+ else if (o === 4 && firstApply && typeof v === 'string') {
4674
+ if (v !== '')
4675
+ slots[slotIdx[i]].setAttribute(aux[i], v);
3479
4676
  }
3480
4677
 
3481
- stamper.nodeMarker = marker;
3482
- stamper.parentNg = this;
3483
- stamper.applySingle(exprs[i]);
4678
+ // The list key never touches the DOM.
4679
+ else if (o === 1)
4680
+ this.key = v;
4681
+
4682
+ // Everything else (attributes, disabled delegation, odd values) goes through
4683
+ // the shared stamper's full applySingle() semantics.
4684
+ else {
4685
+ let stamper = stampers[i];
4686
+ stamper.nodeMarker = slots[slotIdx[i]];
4687
+ stamper.parentNg = this;
4688
+ stamper.applySingle(v);
4689
+ }
3484
4690
  }
3485
4691
 
3486
4692
  this.nodesCache = null;
@@ -3494,7 +4700,7 @@ class NodeGroup {
3494
4700
  * @return {boolean} False when a child-node expression isn't primitive; the caller
3495
4701
  * must then materialize paths and apply normally. */
3496
4702
  rewriteStamp(template) {
3497
- let shell = Shell.get(template.html, template.svgMode);
4703
+ let shell = this.shell;
3498
4704
  let newExprs = template.exprs;
3499
4705
  let nodesIdx = shell.nodesPathIdx;
3500
4706
  for (let i=0; i<nodesIdx.length; i++) {
@@ -3504,19 +4710,29 @@ class NodeGroup {
3504
4710
  }
3505
4711
 
3506
4712
  let oldExprs = this.template.exprs;
3507
- let paths = shell.paths, stampers = shell.stampPaths;
3508
- let slots = null; // Nodes are resolved only if something actually changed.
3509
- for (let i = paths.length - 1; i >= 0; i--) {
3510
- if (!exprSame(oldExprs[i], newExprs[i])) {
4713
+ let stampers = shell.stampPaths, slotIdx = shell.stampSlot, flags = shell.stampFlags;
4714
+ let slots = this.stampSlotsCache; // Nodes are resolved only if something actually changed, then cached.
4715
+ for (let i = stampers.length - 1; i >= 0; i--) {
4716
+ // Live HTML properties (checked etc., boolean-valued) are exempt from the
4717
+ // unchanged-value skip: a user's click flips the DOM property underneath the cached
4718
+ // expression, and applySingle() compares against the live node before writing.
4719
+ // The identity test is inline because most expressions are unchanged, and reaching
4720
+ // exprSame() only to be told so costs more than the comparison itself.
4721
+ let oldExpr = oldExprs[i], newExpr = newExprs[i];
4722
+ let flag = flags[i];
4723
+ if ((oldExpr !== newExpr && !exprSame(oldExpr, newExpr))
4724
+ || ((flag & 1) && typeof newExpr === 'boolean')) {
4725
+ // .slice() is required: resolveStampSlots returns the Shell's SHARED scratch
4726
+ // array, which the next row's resolve would overwrite.
3511
4727
  if (slots === null)
3512
- slots = this.resolveStampSlots(shell);
4728
+ slots = this.stampSlotsCache = this.resolveStampSlots(shell).slice();
3513
4729
  let stamper = stampers[i];
3514
- let marker = slots[paths[i].markerSlot];
4730
+ let marker = slots[slotIdx[i]]; // The flat slot array, so the Path isn't loaded.
3515
4731
 
3516
4732
  // Fast path for a wholeParent text path whose child already exists (the common
3517
4733
  // rewrite case): set its value directly, skipping applySingle's branching and
3518
4734
  // textNode bookkeeping. exprSame above already proved it changed.
3519
- if (stamper.wholeParent) {
4735
+ if (flag & 2) {
3520
4736
  let v = newExprs[i], tn = marker.firstChild;
3521
4737
  if (typeof v === 'number')
3522
4738
  v += '';
@@ -3551,16 +4767,10 @@ class NodeGroup {
3551
4767
  * @return {Node[]} The shell's shared scratch slots array. */
3552
4768
  resolveStampSlots(shell) {
3553
4769
  let slots = shell.resolveSlots;
4770
+ // A singleRoot shell's first op pair is always [0, 0], so slot 1 is the row's own root
4771
+ // element and the program can start at the second pair.
3554
4772
  slots[1] = this.startNode;
3555
- let ops = shell.resolveOps;
3556
- // firstChild/nextSibling pointer walk; see setPathsFromFragment for why not childNodes[i].
3557
- for (let i=2, s=2; i<ops.length; i+=2, s++) {
3558
- let node = slots[ops[i]].firstChild;
3559
- for (let k=ops[i+1]; k>0; k--)
3560
- node = node.nextSibling;
3561
- slots[s] = node;
3562
- }
3563
- return slots;
4773
+ return runResolveOps(slots, shell.resolveOps, 2, 2);
3564
4774
  }
3565
4775
 
3566
4776
  /**
@@ -3570,17 +4780,8 @@ class NodeGroup {
3570
4780
  * @param shell {?Shell}
3571
4781
  * @return {Path[]} */
3572
4782
  materializePaths(shell=null) {
3573
- shell ??= Shell.get(this.template.html, this.template.svgMode);
3574
- let slots = this.resolveStampSlots(shell);
3575
- let paths = shell.paths;
3576
- let pathLength = paths.length;
3577
- let result = this.paths = new Array(pathLength);
3578
- for (let i=0; i<pathLength; i++) {
3579
- let p = paths[i];
3580
- let path = p.cloneWithNodes(p.beforeSlot >= 0 ? slots[p.beforeSlot] : null, slots[p.markerSlot]);
3581
- path.parentNg = this;
3582
- result[i] = path;
3583
- }
4783
+ shell ??= this.shell;
4784
+ let result = this.clonePathsFromSlots(shell, this.resolveStampSlots(shell));
3584
4785
 
3585
4786
  // A wholeParent child-node path that stamped a primitive left exactly one Text child.
3586
4787
  for (let idx of shell.nodesPathIdx) {
@@ -3620,14 +4821,8 @@ class NodeGroup {
3620
4821
  /**
3621
4822
  * Get the root element of the NodeGroup's RootNodeGroup.
3622
4823
  * @returns {HTMLElement|DocumentFragment} */
3623
- getRootNode() {
3624
- return this.rootNg.root;
3625
- }
3626
-
3627
- /**
3628
- * @returns {RootNodeGroup} */
3629
- getRootNodeGroup() {
3630
- return this.rootNg;
4824
+ getRootEl() {
4825
+ return this.rootNg.rootEl;
3631
4826
  }
3632
4827
 
3633
4828
  /**
@@ -3638,9 +4833,6 @@ class NodeGroup {
3638
4833
  * @param isRootClone {boolean} True when fragment is a direct clone of a singleRoot
3639
4834
  * shell's root element: it fills slot 1 itself and the first op pair is skipped. */
3640
4835
  setPathsFromFragment(fragment, shell, startingPathDepth=0, isRootClone=false) {
3641
- let paths = shell.paths;
3642
- let pathLength = paths.length; // For faster iteration
3643
- let result = this.paths = new Array(pathLength);
3644
4836
 
3645
4837
  // Fast path: run the shell's precomputed resolve program (see Shell.buildResolveProgram).
3646
4838
  // Each Path.clone() would walk childNodes from the fragment root to its target node,
@@ -3652,37 +4844,45 @@ class NodeGroup {
3652
4844
  // attribPaths behavior; pathOffset!==0 (root grafting) also uses the fallback.
3653
4845
  let ops = shell.resolveOps;
3654
4846
  if (ops && startingPathDepth === 0) {
3655
- let slots = shell.resolveSlots;
3656
- let i = 0, s = 1;
3657
- if (isRootClone) { // Slot 1 is the root element itself; skip its op pair.
3658
- slots[1] = fragment;
3659
- i = 2;
3660
- s = 2;
3661
- }
3662
- else
4847
+ let slots;
4848
+ if (isRootClone) // The root element is also this.startNode, so it seeds slot 1 itself.
4849
+ slots = this.resolveStampSlots(shell);
4850
+ else {
4851
+ slots = shell.resolveSlots;
3663
4852
  slots[0] = fragment;
3664
- // Resolve each node via firstChild/nextSibling pointer walks instead of
3665
- // childNodes[index]; the live NodeList indexing is markedly slower, and indices
3666
- // are small (markers are elements, often the first child after whitespace stripping).
3667
- for (; i<ops.length; i+=2, s++) {
3668
- let node = slots[ops[i]].firstChild;
3669
- for (let k=ops[i+1]; k>0; k--)
3670
- node = node.nextSibling;
3671
- slots[s] = node;
3672
- }
3673
- for (let i=0; i<pathLength; i++) {
3674
- let p = paths[i];
3675
- let path = p.cloneWithNodes(p.beforeSlot >= 0 ? slots[p.beforeSlot] : null, slots[p.markerSlot]);
3676
- path.parentNg = this;
3677
- result[i] = path;
4853
+ runResolveOps(slots, ops, 0, 1);
3678
4854
  }
4855
+ this.clonePathsFromSlots(shell, slots);
3679
4856
  }
3680
- else
4857
+ else {
4858
+ let paths = shell.paths;
4859
+ let pathLength = paths.length; // For faster iteration
4860
+ let result = this.paths = new Array(pathLength);
3681
4861
  for (let i=0; i<pathLength; i++) {
3682
4862
  let path = paths[i].clone(fragment, startingPathDepth);
3683
4863
  path.parentNg = this;
3684
4864
  result[i] = path;
3685
4865
  }
4866
+ }
4867
+ }
4868
+
4869
+ /**
4870
+ * Copy the shell's Paths onto this NodeGroup's own nodes, taking each path's marker and
4871
+ * before-node from the slots the resolve program just filled.
4872
+ * @param shell {Shell}
4873
+ * @param slots {Node[]} The shell's shared scratch slots, already resolved.
4874
+ * @return {Path[]} */
4875
+ clonePathsFromSlots(shell, slots) {
4876
+ let paths = shell.paths;
4877
+ let pathLength = paths.length;
4878
+ let result = this.paths = new Array(pathLength);
4879
+ for (let i=0; i<pathLength; i++) {
4880
+ let p = paths[i];
4881
+ let path = p.cloneWithNodes(p.beforeSlot >= 0 ? slots[p.beforeSlot] : null, slots[p.markerSlot]);
4882
+ path.parentNg = this;
4883
+ result[i] = path;
4884
+ }
4885
+ return result;
3686
4886
  }
3687
4887
 
3688
4888
  updateStyles() {
@@ -3690,7 +4890,7 @@ class NodeGroup {
3690
4890
  for (let [style, oldText] of this.styles) {
3691
4891
  let newText = style.textContent;
3692
4892
  if (oldText !== newText)
3693
- Util.bindStyles(style, this.getRootNodeGroup().root);
4893
+ Util.bindStyles(style, this.rootNg.rootEl);
3694
4894
  }
3695
4895
  }
3696
4896
 
@@ -3700,16 +4900,14 @@ class NodeGroup {
3700
4900
  * @param pathOffset {int} */
3701
4901
  activateEmbeds(root, shell, pathOffset=0) {
3702
4902
 
3703
- let rootEl = this.rootNg.root;
4903
+ let rootEl = this.rootNg.rootEl;
3704
4904
  if (rootEl) {
3705
- let options = this.rootNg.options;
4905
+ let options = this.rootNg.renderOptions;
3706
4906
 
3707
4907
  // ids
3708
4908
  if (options?.ids !== false) {
3709
4909
  for (let path of shell.ids) {
3710
- if (pathOffset)
3711
- path = path.slice(0, -pathOffset);
3712
- let el = Path.resolve(root, path);
4910
+ let el = Path.resolve(root, path, pathOffset);
3713
4911
  Util.bindId(rootEl, el);
3714
4912
  }
3715
4913
  }
@@ -3719,11 +4917,8 @@ class NodeGroup {
3719
4917
  if (shell.styles.length)
3720
4918
  this.styles = new Map();
3721
4919
  for (let path of shell.styles) {
3722
- if (pathOffset)
3723
- path = path.slice(0, -pathOffset);
3724
-
3725
4920
  /** @type {HTMLStyleElement} */
3726
- let style = Path.resolve(root, path);
4921
+ let style = Path.resolve(root, path, pathOffset);
3727
4922
  if (rootEl.nodeType === 1) {
3728
4923
  Util.bindStyles(style, rootEl);
3729
4924
  this.styles.set(style, style.textContent);
@@ -3734,9 +4929,7 @@ class NodeGroup {
3734
4929
  // scripts
3735
4930
  if (options?.scripts !== false) {
3736
4931
  for (let path of shell.scripts) {
3737
- if (pathOffset)
3738
- path = path.slice(0, -pathOffset);
3739
- let script = Path.resolve(root, path);
4932
+ let script = Path.resolve(root, path, pathOffset);
3740
4933
  // Indirect eval runs in global scope (correct for a <script> tag) and, unlike a direct
3741
4934
  // eval, doesn't force terser to keep every top-level name in the bundle unmangled.
3742
4935
  (0, eval)(script.textContent);
@@ -3752,8 +4945,8 @@ class NodeGroup {
3752
4945
  * Has these properties not present on NodeGroup, assigned by instantiate():
3753
4946
  * They're not declared as fields because subclass field initializers run after the
3754
4947
  * super constructor and would overwrite the assigned values.
3755
- * @property {HTMLElement} root - Root node at the top of the hierarchy.
3756
- * @property {?object} options - RenderOptions */
4948
+ * @property {HTMLElement} rootEl - Root node at the top of the hierarchy.
4949
+ * @property {?object} renderOptions - RenderOptions */
3757
4950
  class RootNodeGroup extends NodeGroup {
3758
4951
 
3759
4952
  /**
@@ -3762,37 +4955,54 @@ class RootNodeGroup extends NodeGroup {
3762
4955
  * Called by the NodeGroup constructor. */
3763
4956
  instantiate(shell, shellFragment, el, options) {
3764
4957
  let startingPathDepth = 0;
3765
- this.options = options;
4958
+ this.renderOptions = options;
3766
4959
  if (shellFragment instanceof Text) {
3767
4960
  if (!el)
3768
- throw new Error('Cannot create a standalone text node');
4961
+ throw new Error('Text node needs an element.');
3769
4962
 
3770
- this.root = el;
4963
+ this.rootEl = el;
3771
4964
  if (shellFragment.nodeValue.length)
3772
- this.root.append(shellFragment);
4965
+ this.rootEl.append(shellFragment);
3773
4966
  }
3774
4967
 
3775
4968
  else {
3776
4969
  if (el) {
3777
- this.root = el;
4970
+ this.rootEl = el;
4971
+
4972
+ // Save the children that belong in this component's <slot>, from one of two places:
4973
+ // 1. A hand-off parked by PathToComponent.applyAll() just before it constructed
4974
+ // us, when this component was declared inside another template. It carries
4975
+ // the Constructor it was meant for, so an unrelated component built in the
4976
+ // meantime -- a field initializer creating a menu, say -- leaves it alone.
4977
+ // 2. el.childNodes, when render() is called manually for the first time.
4978
+ // An addressed hand-off wins even when its node list is empty: a component
4979
+ // declared as <my-tag></my-tag> is asking for an empty slot, not for whatever
4980
+ // its own constructor happened to put in the element.
4981
+ //
4982
+ // The hand-off is deliberately NOT cleared on read. A component that builds
4983
+ // another instance of its OWN class while constructing cannot be told apart
4984
+ // from itself by any address, so both match; the inner one takes the nodes and
4985
+ // this outer one takes them straight back, which is the only thing that makes
4986
+ // that case work.
4987
+ let handOff = Globals$1.currentSlotChildren;
4988
+ let mySlotNodes = handOff?.Constructor === el.constructor
4989
+ ? handOff.nodes
4990
+ : (el.childNodes.length ? [...el.childNodes] : null);
3778
4991
 
3779
- // Save slot
3780
- // 1. Globals.currentSlotChildren is set if this is called via PathToComponent.applyComponent() calls render()
3781
- // 2. el.childNodes is set if render() is called manually for the first time.
3782
4992
  let slotChildren;
3783
- if (Globals$1.currentSlotChildren || el.childNodes.length) {
4993
+ if (mySlotNodes) {
3784
4994
  slotChildren = Globals$1.doc.createDocumentFragment();
3785
- slotChildren.append(...(Globals$1.currentSlotChildren || el.childNodes));
4995
+ slotChildren.append(...mySlotNodes);
3786
4996
  }
3787
4997
 
3788
4998
  // If el should replace the root node of the fragment.
3789
- if (isReplaceEl(shellFragment, this.root.tagName)) {
3790
- this.root.append(...shellFragment.children[0].childNodes);
4999
+ if (isReplaceEl(shellFragment, this.rootEl.tagName)) {
5000
+ this.rootEl.append(...shellFragment.children[0].childNodes);
3791
5001
 
3792
5002
  // Copy attributes
3793
5003
  for (let attrib of shellFragment.children[0].attributes)
3794
- if (!this.root.hasAttribute(attrib.name))
3795
- this.root.setAttribute(attrib.name, attrib.value);
5004
+ if (!this.rootEl.hasAttribute(attrib.name))
5005
+ this.rootEl.setAttribute(attrib.name, attrib.value);
3796
5006
 
3797
5007
  // Go one level deeper into all of shell's paths.
3798
5008
  startingPathDepth = 1;
@@ -3801,7 +5011,7 @@ class RootNodeGroup extends NodeGroup {
3801
5011
  else {
3802
5012
  let isEmpty = shellFragment.childNodes.length === 1 && shellFragment.childNodes[0].nodeType === 3 && shellFragment.childNodes[0].textContent === '';
3803
5013
  if (!isEmpty)
3804
- this.root.append(...shellFragment.childNodes);
5014
+ this.rootEl.append(...shellFragment.childNodes);
3805
5015
  }
3806
5016
 
3807
5017
 
@@ -3827,34 +5037,26 @@ class RootNodeGroup extends NodeGroup {
3827
5037
 
3828
5038
  // Instantiate as a standalone element.
3829
5039
  else {
3830
- let onlyChild = getSingleEl(shellFragment);
3831
- this.root = onlyChild || shellFragment; // We return the whole fragment when calling h() with a collection of nodes.
5040
+ // Trimming the whitespace and comment nodes off both ends leaves a list of exactly
5041
+ // one node only when the fragment has exactly one node worth keeping, which is the
5042
+ // question being asked here.
5043
+ let relevantNodes = Util.trimEmptyNodes(shellFragment.childNodes);
5044
+ let onlyChild = relevantNodes.length === 1 ? relevantNodes[0] : null;
5045
+ this.rootEl = onlyChild || shellFragment; // We return the whole fragment when calling h() with a collection of nodes.
3832
5046
  if (onlyChild)
3833
5047
  startingPathDepth = 1;
3834
5048
  }
3835
5049
 
3836
- this.setPathsFromFragment(this.root, shell, startingPathDepth);
3837
- this.activateEmbeds(this.root, shell, startingPathDepth);
5050
+ this.setPathsFromFragment(this.rootEl, shell, startingPathDepth);
5051
+ this.activateEmbeds(this.rootEl, shell, startingPathDepth);
3838
5052
  }
3839
- this.startNode = this.endNode = this.root;
5053
+ this.startNode = this.endNode = this.rootEl;
3840
5054
 
3841
- Globals$1.rootNodeGroups.set(this.root, this);
5055
+ Globals$1.rootNodeGroups.set(this.rootEl, this);
3842
5056
  }
3843
5057
  }
3844
5058
 
3845
5059
 
3846
- function getSingleEl(fragment) {
3847
- let nonempty = [];
3848
- for (let n of fragment.childNodes) {
3849
- if (n.nodeType === 1 || n.nodeType === 3 && n.textContent.trim().length) {
3850
- if (nonempty.length)
3851
- return null;
3852
- nonempty.push(n);
3853
- }
3854
- }
3855
- return nonempty[0];
3856
- }
3857
-
3858
5060
  /**
3859
5061
  * Does the fragment have one child that's an element matching the tagname of el?
3860
5062
  * @param fragment {DocumentFragment}
@@ -3933,8 +5135,12 @@ class Template {
3933
5135
  if (!ng) {
3934
5136
  ng = new RootNodeGroup(this, null, el, options);
3935
5137
  if (!el) // null if it's a standalone elment.
3936
- el = ng.getRootNode();
3937
- Globals$1.rootNodeGroups.set(el, ng); // All tests still pass if this is commented out!
5138
+ el = ng.getRootEl();
5139
+
5140
+ // RootNodeGroup.instantiate() ends by registering itself under its own rootEl, which
5141
+ // is the element we were given, or -- when we were given none -- the very element
5142
+ // getRootEl() just handed back. Registering it a second time here stored the same
5143
+ // group under the same key.
3938
5144
  }
3939
5145
 
3940
5146
  // Make sure the expresion count matches match the Path "hole" count.
@@ -3949,8 +5155,13 @@ class Template {
3949
5155
  // If we didn't just create it, we need to render it.
3950
5156
  if (this.html?.length === 1 && !this.html[0]) // An empty string.
3951
5157
  el.innerHTML = ''; // Fast path for empty component.
3952
- else
3953
- ng.applyExprs(this.exprs);
5158
+ else {
5159
+ // A component renders the same template every time, so hand over the expressions it
5160
+ // applied last time; paths that can prove an unchanged expression is a no-op skip.
5161
+ let last = ng.template;
5162
+ ng.applyExprs(this.exprs, true, last !== this && last.html === this.html ? last.exprs : null);
5163
+ ng.template = this;
5164
+ }
3954
5165
 
3955
5166
  return el;
3956
5167
  }
@@ -3980,9 +5191,13 @@ class Template {
3980
5191
  function templatesSame(a, b) {
3981
5192
  if (a.html === b.html && a.svgMode === b.svgMode) {
3982
5193
  let ae = a.exprs, be = b.exprs;
3983
- for (let i=0; i<ae.length; i++)
3984
- if (!exprSame(ae[i], be[i]))
5194
+ // Most expressions are identical between renders, so test that here rather than paying
5195
+ // a call into exprSame() to learn it.
5196
+ for (let i=0; i<ae.length; i++) {
5197
+ let x = ae[i], y = be[i];
5198
+ if (x !== y && !exprSame(x, y))
3985
5199
  return false;
5200
+ }
3986
5201
  return true;
3987
5202
  }
3988
5203
 
@@ -4088,7 +5303,7 @@ function toEl(arg) {
4088
5303
  let obj = arg;
4089
5304
 
4090
5305
  if (obj.constructor.name !== 'Object')
4091
- throw new Error(`Solarate Web Component class ${obj.constructor?.name} must extend HTMLElement.`);
5306
+ throw new Error(`Solarite web component class ${obj.constructor?.name} must extend HTMLElement.`);
4092
5307
 
4093
5308
  // Normal path
4094
5309
  if (!Globals$1.objToEl.has(obj)) {
@@ -4176,7 +5391,11 @@ const renderTemplateKey = Symbol('solariteRender');
4176
5391
  // Using `arguments` alongside rest params would force the engine to materialize both per call.
4177
5392
  const noArg = Symbol();
4178
5393
 
4179
- function h(htmlStrings=noArg, ...exprs) {
5394
+ // The /** @type {*} */ cast on the default keeps TypeScript from inferring the parameter as
5395
+ // `symbol` from noArg: TS can't parse the closure-style @param type above (function() without
5396
+ // a return type under noImplicitAny), falls back to the default's type, and then flags every
5397
+ // h(this) / h`` call in the codebase as an error. JetBrains reads the @param fine either way.
5398
+ function h(htmlStrings=/** @type {*} */(noArg), ...exprs) {
4180
5399
 
4181
5400
  // 1. Tagged template: h`<div>...</div>`
4182
5401
  if (Array.isArray(htmlStrings)) {
@@ -4229,11 +5448,14 @@ function h(htmlStrings=noArg, ...exprs) {
4229
5448
  let parent = htmlStrings, options = exprs[0];
4230
5449
 
4231
5450
  // The closure is cached on the element so repeated renders don't recreate it.
4232
- if (options === undefined) {
4233
- let cached = parent[renderTemplateKey];
4234
- if (cached)
4235
- return cached;
4236
- }
5451
+ // Options are cached with it: they only take effect when the element's
5452
+ // RootNodeGroup is first created, so a later render passing different ones is
5453
+ // ignored either way, and caching regardless of them saves an allocation on every
5454
+ // render of a component that passes an options object — which is how render() is
5455
+ // usually written.
5456
+ let cached = parent[renderTemplateKey];
5457
+ if (cached)
5458
+ return cached;
4237
5459
 
4238
5460
  // Return a tagged template function that applies the tagged template to parent.
4239
5461
  let renderTemplate = (htmlStrings, ...exprs) => {
@@ -4245,8 +5467,7 @@ function h(htmlStrings=noArg, ...exprs) {
4245
5467
  let template = new Template(htmlStrings, exprs);
4246
5468
  return template.render(parent, options);
4247
5469
  };
4248
- if (options === undefined)
4249
- parent[renderTemplateKey] = renderTemplate;
5470
+ parent[renderTemplateKey] = renderTemplate;
4250
5471
  return renderTemplate;
4251
5472
  }
4252
5473
  }
@@ -4263,11 +5484,11 @@ function h(htmlStrings=noArg, ...exprs) {
4263
5484
  // Intercepts the main h(this)`...` function call inside render().
4264
5485
  // TODO: This path doesn't handle embeds like data-id="..."
4265
5486
  else if (typeof htmlStrings === 'object' && Globals$1.objToEl.has(htmlStrings)) {
5487
+ // The only thing that ever puts an object into objToEl is toEl(), and it rejects anything
5488
+ // that isn't a plain object before it does so, so an object that reaches here has already
5489
+ // been checked and re-checking it can never report anything.
4266
5490
  let obj = htmlStrings;
4267
5491
 
4268
- if (obj.constructor.name !== 'Object')
4269
- throw new Error(`Solarate Web Component class ${obj.constructor?.name} must extend HTMLElement.`);
4270
-
4271
5492
  // Jsx with h(this, <jsx>)
4272
5493
  if (exprs[0] instanceof Template) {
4273
5494
  let template = exprs[0];
@@ -4291,14 +5512,6 @@ function h(htmlStrings=noArg, ...exprs) {
4291
5512
  throw new Error('h() does not support argument of type: ' + (htmlStrings ? typeof htmlStrings : htmlStrings))
4292
5513
  }
4293
5514
 
4294
- // h.map caches each item's Template keyed by the item's identity, so a re-render returns
4295
- // the SAME Template instance for any item whose reference is unchanged. The reconciler's
4296
- // `ng.template === item` fast path (PathToNodes.applyKeyed/applyDiff) then skips rebuilding
4297
- // and comparing that row. A WeakMap is used instead of a symbol property so the idiomatic
4298
- // immutable update `{...item, x}` yields a fresh object that ISN'T in the cache and re-renders;
4299
- // a symbol property would be copied by spread and silently reuse the stale Template.
4300
- const mapCache = new WeakMap();
4301
-
4302
5515
  /**
4303
5516
  * Render a list, reusing each item's DOM for as long as the item is the SAME object.
4304
5517
  *
@@ -4315,37 +5528,73 @@ const mapCache = new WeakMap();
4315
5528
  *
4316
5529
  * ${h.map(this.rows, row => h`<tr key=${row.id}>${row.label}</tr>`)}
4317
5530
  *
5531
+ * What comes back is a MappedList, not an array: it carries the items and the callback so
5532
+ * the reconciler can match a row to its item by identity and call the callback only for the
5533
+ * rows it can't match. Put it straight into a template expression, as above; nested inside
5534
+ * an array, or returned from a function, it expands to Templates just the same.
5535
+ *
4318
5536
  * @param items {Array} The list to render.
4319
5537
  * @param fn {function(item:*):Template} Builds an item's Template; called only for new items.
4320
- * @return {Template[]} */
4321
- h.map = (items, fn) => {
4322
- let result = new Array(items.length);
4323
- for (let i=0; i<items.length; i++) {
4324
- let item = items[i];
4325
- if (item !== null && typeof item === 'object') {
4326
- let template = mapCache.get(item);
4327
- if (template === undefined) {
4328
- template = fn(item);
4329
- mapCache.set(item, template);
4330
- }
4331
- result[i] = template;
4332
- }
4333
- else
4334
- result[i] = fn(item);
4335
- }
4336
- return result;
4337
- };
5538
+ * @return {MappedList} */
5539
+ h.map = (items, fn) => new MappedList(items, fn);
4338
5540
 
4339
5541
  h.immutableMap = h.map;
4340
5542
 
4341
- /*
4342
- ┏┓ ┓ •
4343
- ┗┓┏┓┃┏┓┏┓┓╋▗▖
4344
- ┗┛┗┛┗┗┻╹ ╹╹┗
4345
- JavaScript UI library
4346
- @license MIT
4347
- @copyright Vorticode LLC
4348
- https://vorticode.github.io/solarite/ */
5543
+ /**
5544
+ * Create a selection that updates only the rows it affects.
5545
+ *
5546
+ * A highlight that moves from one row of a thousand to another changes two attributes.
5547
+ * Expressing it as ordinary state means calling render() and letting the reconciler walk the
5548
+ * list to rediscover that. A selector writes those two attributes directly instead:
5549
+ *
5550
+ * class Table extends Solarite {
5551
+ * selected = h.selector();
5552
+ *
5553
+ * pick(row) {
5554
+ * this.selected.set(row.id); // no render() call
5555
+ * }
5556
+ *
5557
+ * render() {
5558
+ * h(this)`<tbody>${h.map(this.rows, row =>
5559
+ * h`<tr key=${row.id} class=${this.selected.when(row.id, 'danger')}
5560
+ * onclick=${[this.pick, row]}>${row.label}</tr>`)}</tbody>`;
5561
+ * }
5562
+ * }
5563
+ *
5564
+ * when() must be a whole attribute value, not part of one and not element content, since it
5565
+ * owns that attribute for as long as the row exists. An off value of '' leaves no attribute
5566
+ * behind at all. Selection state lives on the selector, so it survives re-renders, and
5567
+ * set() is safe to call whether or not the rows are currently rendered.
5568
+ *
5569
+ * Two rules follow from how set() finds a row, and both throw a clear error rather than
5570
+ * misbehaving quietly. **The rows must be keyed** — set() locates a row by looking its key
5571
+ * up in the list, so the row template needs a key=${...}. And **the attribute must sit on
5572
+ * the row's own root element**, the same one that carries the key, because that is the
5573
+ * element set() writes. Drawing a row costs nothing either way: when() hands back one of
5574
+ * two shared objects rather than allocating anything per row, so a selector is free to
5575
+ * render over a list of any size and only a change of selection does any work.
5576
+ *
5577
+ * @param key {*} The initially selected key, or null for none.
5578
+ * @return {Selector} */
5579
+ h.selector = (key = null) => new Selector(key);
5580
+
5581
+ /**
5582
+ * Convert an attribute string with the given converter: Number, Boolean, String, Date,
5583
+ * or any function taking the string and returning a value. Boolean is true for any string
5584
+ * except 'false' and '0', so a bare attribute like `<my-timer auto-start>` reads as true.
5585
+ * Date uses new Date(value). No converter returns the string unchanged. */
5586
+ function convertType(value, type) {
5587
+ if (type === Date)
5588
+ return new Date(value);
5589
+ if (type === Boolean)
5590
+ return !['false', '0'].includes(value);
5591
+ // Number and String need no cases of their own: they're plain functions, so the custom
5592
+ // branch below calls them correctly. Date and Boolean are the ones that can't fall through
5593
+ // (Date without `new` returns a string; Boolean('false') is true).
5594
+ if (type) // Number, String, or a custom string=>value function
5595
+ return type(value);
5596
+ return value;
5597
+ }
4349
5598
 
4350
5599
  /**
4351
5600
  * Read an element's html attributes onto fields that already exist on the element.
@@ -4381,16 +5630,8 @@ function assignAttributes(dest, types={}, ignore=[]) {
4381
5630
  dest[name] = JSON.parse(value.slice(2, -1));
4382
5631
 
4383
5632
  // 2. Cast the string with the converter named in `types`, if any.
4384
- else if (type === Date)
4385
- dest[name] = new Date(value);
4386
- else if (type === Boolean)
4387
- dest[name] = !['false', '0'].includes(value);
4388
- else if (type === Number)
4389
- dest[name] = Number(value);
4390
- else if (type === String)
4391
- dest[name] = String(value);
4392
- else if (type) // custom string=>value function
4393
- dest[name] = type(value);
5633
+ else if (type)
5634
+ dest[name] = convertType(value, type);
4394
5635
 
4395
5636
  // 3. No converter named: assign the raw string. But an empty value over a function/object
4396
5637
  // field is just the serialization residue of a template expression (functions render as
@@ -4440,42 +5681,46 @@ let HTMLElementAutoDefine = new Proxy(HTMLElement, {
4440
5681
  class Solarite extends HTMLElementAutoDefine {
4441
5682
 
4442
5683
  /**
4443
- * @param attribs {?Record<string, any>} */
4444
- constructor(attribs=null) {
5684
+ * Fill in and fix up the attribs object a component's constructor receives, so the component
5685
+ * can then copy those values onto its own fields, e.g. with ObjectUtil.assign(this, attribs).
5686
+ *
5687
+ * 1. If attribs is an empty object, fill it with the attributes on the DOM element.
5688
+ * This happens when the browser creates the element from plain html, because then nothing
5689
+ * calls the constructor with arguments. Attribute names convert from dash-case to
5690
+ * camelCase, and `${...}` values are parsed from JSON.
5691
+ * 2. If types is given, convert attribs values from strings to those types. Attribute values
5692
+ * written as literal text always arrive as strings, whether from plain html or from an h()
5693
+ * template. types maps a field name to Number, Boolean, String, Date, or any function
5694
+ * taking the string and returning a value. Boolean is true for every string except
5695
+ * 'false' and '0', so a bare attribute like `<select-box-3 editable>` becomes true.
5696
+ * Values that are already not strings, like a `${true}` template expression, are left alone.
5697
+ *
5698
+ * This runs before the subclass initializes its fields and renders, so converted values are
5699
+ * right the first time, even for fields that change what render() builds. This constructor
5700
+ * can't copy attribs onto fields itself, because subclass field initializers run after it
5701
+ * finishes and would overwrite them; that's why the subclass does the final assign.
5702
+ * @param attribs {?Record<string, any>}
5703
+ * @param types {?Record<string, Function>} */
5704
+ constructor(attribs=null, types=null) {
4445
5705
  super();
4446
5706
 
4447
5707
  if (attribs) {
4448
5708
  if (typeof attribs !== 'object')
4449
- throw new Error('First argument to custom element constructor must be an object.');
5709
+ throw new Error('First argument must be an object.');
4450
5710
 
4451
5711
  // 1. Populate attribs if it's an empty object.
4452
- if (attribs && !Object.keys(attribs).length) {
5712
+ if (!Object.keys(attribs).length) {
4453
5713
  let attribs2 = Solarite.getAttribs(this);
4454
5714
  for (let name in attribs2) {
4455
5715
  attribs[name] = attribs2[name];
4456
5716
  }
4457
5717
  }
4458
5718
 
4459
- // 2. Populate fields from attribs.
4460
- // This does nothing because the fields are overwritten by the child class after this super() constructor executes.
4461
- //for (let name in attribs || {}) {
4462
- // if (name in this) {
4463
- // const descriptor = Object.getOwnPropertyDescriptor(this, name);
4464
- // if (!descriptor || descriptor.writable || descriptor.set)
4465
- // this[name] = attribs[name];
4466
- // }
4467
- //}
5719
+ // 2. Convert string values to the types the component declares.
5720
+ for (let name in types || {})
5721
+ if (typeof attribs[name] === 'string')
5722
+ attribs[name] = convertType(attribs[name], types[name]);
4468
5723
  }
4469
-
4470
- // 3. Wrap render function so it always provides the attribs argument.
4471
- // Disabled because this gives us strings for attribute values when we call render manually.
4472
- // Instead of values given from ${...} expressions.
4473
- // let originalRender = this.render;
4474
- // this.render = (attribs, changed=true) => {
4475
- // if (!attribs) // If we have to look up the attribs, we don't know if they changed or not.
4476
- // attribs = Solarite.getAttribs(this);
4477
- // originalRender.call(this, attribs, changed);
4478
- // }
4479
5724
  }
4480
5725
 
4481
5726
  'render'() {
@@ -4618,4 +5863,4 @@ class Solarite extends HTMLElementAutoDefine {
4618
5863
  }
4619
5864
 
4620
5865
  export default h;
4621
- export { Fragment, Globals$1 as Globals, Solarite, Util as SolariteUtil, Template, assignAttributes, delve, getEventBinding, h, svg, toEl };
5866
+ 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 };