solarite 0.8.0 → 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
  /**
@@ -315,7 +341,14 @@ let Util = {
315
341
  return node.value; // String
316
342
  },
317
343
 
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. */
318
349
  isEvent(attribName) {
350
+ if (attribName.startsWith(nativeEventPrefix))
351
+ attribName = attribName.slice(nativeEventPrefix.length);
319
352
  return attribName.startsWith('on') && attribName in Globals$1.div;
320
353
  },
321
354
 
@@ -1072,17 +1105,20 @@ class PathToAttribValue extends Path {
1072
1105
 
1073
1106
  // Delegated path: a bubbling event (when the root's options allow it, the default)
1074
1107
  // stores its handler directly on the node as a per-event-type Symbol expando, with no
1075
- // EventBinding object and no addEventListener call. The root-level dispatcher reads
1076
- // these expandos while walking up from the event target. Re-renders just overwrite
1077
- // the property. this.delegatedKey is set by the PathToEvent constructor only for
1078
- // delegatable event names, so this test also excludes non-bubbling events.
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.
1079
1114
  if (capture === false && this.delegatedKey !== undefined) {
1080
1115
  let opt = this.parentNg.rootNg.renderOptions?.eventDelegation ?? true;
1081
- let toDocument = opt === 'document';
1082
- if (opt !== false && (opt === true || toDocument || opt.includes(eventName))) {
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))) {
1083
1119
  let dk = this.delegatedKey;
1084
1120
  if (node[dk] === undefined) // First binding of this type on this node.
1085
- ensureDelegatedDispatcher(root, eventName, toDocument);
1121
+ ensureDelegatedDispatcher(root, eventName);
1086
1122
  // Array-form bindings (onclick=${[fn, arg]}, the hot per-row case) store the
1087
1123
  // template's own [func, ...args] array; a plain function is stored bare.
1088
1124
  // Either way, nothing is allocated.
@@ -1154,7 +1190,7 @@ function getEventBinding(node, key) {
1154
1190
  return b instanceof EventBinding ? (b.key === key ? b : undefined) : b[key];
1155
1191
  }
1156
1192
 
1157
- // Bubbling events that one root-level listener can dispatch. Same set Solid.js delegates.
1193
+ // Bubbling events the just-in-time dispatcher handles. Same set Solid.js delegates.
1158
1194
  const delegatableEvents = new Set(['beforeinput', 'click', 'contextmenu', 'dblclick', 'focusin', 'focusout',
1159
1195
  'input', 'keydown', 'keyup', 'mousedown', 'mousemove', 'mouseout', 'mouseover', 'mouseup',
1160
1196
  'pointerdown', 'pointermove', 'pointerout', 'pointerover', 'pointerup', 'touchend', 'touchmove', 'touchstart']);
@@ -1178,79 +1214,142 @@ function delegatedKeyFor(eventName) {
1178
1214
  // Exported so NodeGroup.applyStamp()'s compiled stamp program can write it directly.
1179
1215
  const delegatedRootKey = Symbol('solariteDelegatedRoot');
1180
1216
 
1181
- // Per-root-element Set of event types that already have a delegated dispatcher registered.
1217
+ // Set of event types that already have the dispatcher registered, kept on each root element
1218
+ // and on each document.
1182
1219
  const delegatedTypesKey = Symbol('solariteDelegatedTypes');
1183
1220
 
1184
1221
  /**
1185
- * Register the delegated dispatcher for eventName on root if it isn't already.
1186
- * Shared by bindEvent()'s delegated branch and NodeGroup.applyStamp()'s stamp program.
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.
1187
1224
  *
1188
- * With andDocument (the eventDelegation:'document' render option), the dispatcher is also
1189
- * registered on the document, once per event type: a bound node that gets re-parented
1190
- * OUTSIDE its root (e.g. a toolbar a dock parks in its own chrome) bubbles past the root's
1191
- * listener, and only a document-level listener can still reach its handler. The
1192
- * delegatedDoneKey marker keeps the two dispatchers from double-running the same event.
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.
1193
1230
  * @param root {HTMLElement}
1194
- * @param eventName {string}
1195
- * @param andDocument {boolean} */
1196
- function ensureDelegatedDispatcher(root, eventName, andDocument=false) {
1231
+ * @param eventName {string} */
1232
+ function ensureDelegatedDispatcher(root, eventName) {
1197
1233
  let types = root[delegatedTypesKey];
1198
1234
  if (types === undefined)
1199
1235
  types = root[delegatedTypesKey] = new Set();
1200
1236
  if (!types.has(eventName)) {
1201
1237
  types.add(eventName);
1202
- root.addEventListener(eventName, delegatedDispatcher);
1203
- }
1204
- if (andDocument) {
1205
- let doc = root.ownerDocument ?? document;
1238
+ root.addEventListener(eventName, jitDispatcher, true);
1239
+
1240
+ let doc = root.ownerDocument;
1206
1241
  let docTypes = doc[delegatedTypesKey];
1207
1242
  if (docTypes === undefined)
1208
1243
  docTypes = doc[delegatedTypesKey] = new Set();
1209
1244
  if (!docTypes.has(eventName)) {
1210
1245
  docTypes.add(eventName);
1211
- doc.addEventListener(eventName, delegatedDispatcher);
1246
+ doc.addEventListener(eventName, jitDispatcher, true);
1212
1247
  }
1213
1248
  }
1214
1249
  }
1215
1250
 
1216
- // Marks an event the innermost root dispatcher has already walked, so an outer root's
1217
- // listener (when components are nested) skips it instead of dispatching the bindings again.
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.
1218
1255
  const delegatedDoneKey = Symbol('solariteDelegated');
1219
1256
 
1220
1257
  /**
1221
- * The per-root listener for each delegated event type. The first (innermost) root the
1222
- * bubbling event reaches walks from the event target upward, invoking delegated handlers
1223
- * stored on the nodes along the way; outer roots then see the done-marker and skip.
1224
- * Each node carries the root its handlers run with as `this` (see delegatedRootKey), so
1225
- * handlers in an outer component still run with the correct component. event.currentTarget
1226
- * is patched to the node whose handler is running, and restored after. stopPropagation()
1227
- * inside a handler ends the walk, mirroring native bubbling. */
1228
- function delegatedDispatcher(ev) {
1229
- if (ev[delegatedDoneKey])
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 = {};
1264
+
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;
1298
+
1299
+ /**
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)
1230
1334
  return;
1231
- ev[delegatedDoneKey] = true;
1232
- let dk = delegatedKeys[ev.type];
1233
- let current = ev.target;
1234
- Object.defineProperty(ev, 'currentTarget', {configurable: true, get() { return current }});
1235
- while (current) {
1236
- let a = current[dk];
1237
- if (a !== undefined) {
1238
- let root = current[delegatedRootKey];
1239
- if (typeof a === 'function')
1240
- a.call(root, ev, current);
1241
- else
1242
- switch (a.length) {
1243
- case 1: a[0].call(root, ev, current); break;
1244
- case 2: a[0].call(root, a[1], ev, current); break;
1245
- case 3: a[0].call(root, a[1], a[2], ev, current); break;
1246
- default: a[0].call(root, ...a.slice(1), ev, current);
1247
- }
1248
- if (ev.cancelBubble)
1249
- break;
1335
+ ev[delegatedDoneKey] = path.length;
1336
+
1337
+ let type = ev.type;
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);
1250
1349
  }
1251
- current = current.parentNode;
1252
1350
  }
1253
- delete ev.currentTarget; // Restore the native getter from the prototype.
1351
+ if (list.length !== 0 && sweepTimer === 0)
1352
+ sweepTimer = setTimeout(sweep);
1254
1353
  }
1255
1354
 
1256
1355
  class EventBinding {
@@ -1287,11 +1386,24 @@ class PathToEvent extends PathToAttribValue {
1287
1386
  * Undefined for non-delegatable (non-bubbling) events; bindEvent() then binds directly. */
1288
1387
  delegatedKey;
1289
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
+
1290
1394
  constructor(nodeBefore, nodeMarker, attribName=null, attrValue=null) {
1291
1395
  super(null, nodeMarker, attribName, attrValue);
1292
1396
  this.skipIfSame = true;
1293
- this.eventName = attribName ? attribName.slice(2) : null;
1294
- this.delegatedKey = this.eventName !== null ? delegatedKeyFor(this.eventName) : undefined;
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;
1295
1407
  }
1296
1408
 
1297
1409
  /**
@@ -3335,106 +3447,120 @@ class PathToComponent extends Path {
3335
3447
  }
3336
3448
  }
3337
3449
 
3338
- // 2. Instantiate component on first time.
3339
- let isAttrib = el.getAttribute('_is');
3340
- if (el.tagName.endsWith('-SOLARITE-PLACEHOLDER') || isAttrib) {
3341
-
3342
-
3343
- // 2a. Instantiate component
3344
- let tagName = (isAttrib || el.tagName.slice(0, -21)).toLowerCase(); // Remove -SOLARITE-PLACEHOLDER
3345
- let Constructor = customElements.get(tagName);
3346
-
3347
- // Not defined yet (e.g. the module is being lazily imported): keep the placeholder
3348
- // and instantiate when the definition lands, like a native custom-element upgrade.
3349
- // deferredExprs always holds the LATEST exprs so re-renders while undefined win.
3350
- if (!Constructor) {
3351
- this.deferredExprs = exprs;
3352
- if (!this.whenDefinedPending) {
3353
- this.whenDefinedPending = true;
3354
- console.warn(`Solarite: <${tagName}> is not defined yet; waiting for customElements.define().`);
3355
- customElements.whenDefined(tagName).then(() => {
3356
- this.whenDefinedPending = false;
3357
- let deferred = this.deferredExprs;
3358
- this.deferredExprs = null;
3359
- // Skip if a newer render already instantiated or replaced the placeholder.
3360
- if (deferred && this.nodeMarker === el && el.tagName.endsWith('-SOLARITE-PLACEHOLDER'))
3361
- this.applyAll(deferred);
3362
- });
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;
3363
3484
  }
3364
- Globals$1.currentSlotChildren = null;
3365
- return;
3366
- }
3367
3485
 
3368
- Globals$1.currentSlotChildren = [...el.childNodes]; // TODO: Does this need to be a stack?
3369
- let newEl = new Constructor(attribs);
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);
3370
3502
 
3371
- // 2b. Copy attributes over.
3372
- if (isAttrib) {
3373
- newEl.setAttribute('is', isAttrib);
3374
- // el.removeAttribute('_is');
3375
- }
3376
- for (let attrib of el.attributes)
3377
- if (attrib.name !== '_is')
3378
- newEl.setAttribute(attrib.name, attrib.value);
3379
-
3380
- // Set dynamic attributes if they are primitive types.
3381
- for (let name in attribs) {
3382
- let val = attribs[name];
3383
- let valType = typeof val;
3384
- // Only true and false can reach here, so the undefined/null halves of the
3385
- // falsy test this used to spell out could never have decided anything.
3386
- if (valType === 'boolean') {
3387
- if (val)
3388
- newEl.setAttribute(name, '');
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
+ }
3513
+
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);
3389
3517
  }
3390
3518
 
3391
- // If type is a non-boolean primitive, set the attribute value.
3392
- else if (valType==='string' || valType === 'number' || valType==='bigint')
3393
- newEl.setAttribute(name, val);
3394
- }
3395
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);
3396
3524
 
3397
- // 2c. If an id pointed at the placeholder, update it to point to the new element.
3398
- let id = newEl.getAttribute('data-id') || newEl.getAttribute('id');
3399
- if (id)
3400
- delve(this.parentNg.getRootEl(), 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
+ }
3401
3551
 
3402
- // 2d. Update paths to use replaced element.
3403
- let ng = this.parentNg;
3404
- this.nodeMarker = newEl;
3405
- for (let path of ng.paths) {
3406
- if (path.nodeMarker === el)
3407
- path.nodeMarker = newEl;
3408
- if (path.nodeBefore === el)
3409
- path.nodeBefore = newEl;
3552
+ // 2e. Swap it to the DOM.
3553
+ el.replaceWith(newEl);
3410
3554
  }
3411
- if (ng.startNode === el)
3412
- ng.startNode = newEl;
3413
- if (ng.endNode === el)
3414
- ng.endNode = newEl;
3415
-
3416
- // 2f. Call render() if it wasn't called by the constructor.
3417
- // This must happen before we add it to the DOM which can trigger connectedCallback() -> renderFirstTime()
3418
- // Because that path renders it without the attribute expressions.
3419
- if (typeof newEl.render === 'function' && !Globals$1.rendered.has(newEl))
3420
- newEl.render(attribs, true);
3421
-
3422
- // 2g. Update attribute paths to use the new element and re-apply them.
3423
- for (let i=0, attribPath; attribPath = this.attribPaths[i]; i++) {
3424
- attribPath.parentNg = this.parentNg;
3425
- attribPath.nodeMarker = newEl;
3426
- attribPath.applyAll(exprs[i]);
3427
- }
3428
-
3429
- // 2e. Swap it to the DOM.
3430
- el.replaceWith(newEl);
3431
- }
3432
3555
 
3433
- // 2f. Render
3434
- else if (typeof el.render === 'function')
3435
- el.render(attribs, changed);
3556
+ // 2f. Render
3557
+ else if (typeof el.render === 'function')
3558
+ el.render(attribs, changed);
3436
3559
 
3437
- Globals$1.currentSlotChildren = null;
3560
+ }
3561
+ finally {
3562
+ Globals$1.currentSlotChildren = prevSlotChildren;
3563
+ }
3438
3564
  }
3439
3565
 
3440
3566
  /**
@@ -3611,6 +3737,9 @@ class Shell {
3611
3737
  // Smaller fragments make cloning, path resolution, and insertion faster.
3612
3738
  stripTableWhitespace(this.docFrag);
3613
3739
 
3740
+ // 1c. Neutralize `is` so the browser can't upgrade a placeholder out from under us.
3741
+ renameIsAttribs(this.docFrag);
3742
+
3614
3743
  // 2. Find placeholders
3615
3744
  let node;
3616
3745
  let toRemove = [];
@@ -3624,7 +3753,7 @@ class Shell {
3624
3753
 
3625
3754
  // Replace attributes
3626
3755
  if (node.nodeType === 1) {
3627
- const hasIs = node.hasAttribute('is');
3756
+ const hasIs = node.hasAttribute('_is'); // Renamed from `is` in step 1c.
3628
3757
  const isComponent = (hasIs || node.tagName.includes('-'));
3629
3758
  const componentAttribPaths = [];
3630
3759
 
@@ -3725,10 +3854,6 @@ class Shell {
3725
3854
  path.attribPaths = componentAttribPaths;
3726
3855
  this.paths.splice(this.paths.length - componentAttribPaths.length, 0, path); // Insert before its componentAttribPaths
3727
3856
 
3728
- if (hasIs) {
3729
- node.setAttribute('_is', node.getAttribute('is'));
3730
- node.removeAttribute('is');
3731
- }
3732
3857
  }
3733
3858
  }
3734
3859
 
@@ -3745,7 +3870,7 @@ class Shell {
3745
3870
  // Components and slots are excluded because they move their children
3746
3871
  // during instantiation, which would orphan the expression's region.
3747
3872
  if (parent.nodeType === 1 && !node.previousSibling && !node.nextSibling
3748
- && !parent.tagName.includes('-') && parent.tagName !== 'SLOT' && !parent.hasAttribute('is')) {
3873
+ && !parent.tagName.includes('-') && parent.tagName !== 'SLOT' && !parent.hasAttribute('_is')) {
3749
3874
  let path = new PathToNodes(null, parent);
3750
3875
  path.wholeParent = true;
3751
3876
  this.paths.push(path);
@@ -4151,6 +4276,38 @@ function stripTableWhitespace(el) {
4151
4276
  }
4152
4277
  }
4153
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
+
4154
4311
  // One-entry memo for Shell.get().
4155
4312
  let lastHtmlStrings = null, lastSvgMode = false, lastShell = null;
4156
4313
 
@@ -4472,17 +4629,17 @@ class NodeGroup {
4472
4629
  let stampers = shell.stampPaths;
4473
4630
  let rootNg = this.rootNg;
4474
4631
  let root = rootNg.rootEl;
4632
+ // Any value other than false or an array of event names means delegate everything.
4475
4633
  let opt = rootNg.renderOptions?.eventDelegation;
4476
- let delegateDoc = opt === 'document';
4477
- let delegateAll = opt === undefined || opt === true || delegateDoc;
4634
+ let delegateAll = opt !== false && !Array.isArray(opt);
4478
4635
 
4479
4636
  // Register this shell's delegated dispatchers once for a whole run of rows. They live on
4480
- // the root, not on the bound nodes, so asking per node — as the general binding path has
4481
- // to — would be a call and a set lookup for every handler in the list.
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.
4482
4639
  let names = shell.stampEventNames;
4483
4640
  if (names !== null && delegateAll && rootNg[lastStampedShellKey] !== shell) {
4484
4641
  for (let k=0; k<names.length; k++)
4485
- ensureDelegatedDispatcher(root, names[k], delegateDoc);
4642
+ ensureDelegatedDispatcher(root, names[k]);
4486
4643
  rootNg[lastStampedShellKey] = shell;
4487
4644
  }
4488
4645
 
@@ -4812,13 +4969,30 @@ class RootNodeGroup extends NodeGroup {
4812
4969
  if (el) {
4813
4970
  this.rootEl = el;
4814
4971
 
4815
- // Save slot
4816
- // 1. Globals.currentSlotChildren is set if this is called via PathToComponent.applyComponent() calls render()
4817
- // 2. el.childNodes is set if render() is called manually for the first time.
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);
4991
+
4818
4992
  let slotChildren;
4819
- if (Globals$1.currentSlotChildren || el.childNodes.length) {
4993
+ if (mySlotNodes) {
4820
4994
  slotChildren = Globals$1.doc.createDocumentFragment();
4821
- slotChildren.append(...(Globals$1.currentSlotChildren || el.childNodes));
4995
+ slotChildren.append(...mySlotNodes);
4822
4996
  }
4823
4997
 
4824
4998
  // If el should replace the root node of the fragment.