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.
@@ -21,8 +21,17 @@ function reset() {
21
21
  connected: new WeakSet(),
22
22
 
23
23
  /**
24
- * Set by NodeGroup.instantiateComponent()
25
- * Used by RootNodeGroup.getSlotChildren(). */
24
+ * A hand-off in flight from PathToComponent.applyAll(), which parks the child nodes
25
+ * declared inside a component's tag here just before constructing it, to that
26
+ * component's RootNodeGroup.instantiate(), which puts them in its <slot>. Null when
27
+ * no hand-off is pending.
28
+ *
29
+ * It is addressed by Constructor rather than by tag name because a customized
30
+ * built-in has no usable tag at the moment it is consumed: a <tr is="my-row">
31
+ * reports a tagName of TR, and its 'is' attribute is not written until after the
32
+ * constructor -- which may already have rendered -- has returned.
33
+ *
34
+ * @type {?{Constructor:Function, nodes:Node[]}} */
26
35
  currentSlotChildren: null,
27
36
 
28
37
  div: document.createElement("div"),
@@ -62,6 +71,17 @@ function reset() {
62
71
  }
63
72
  reset();
64
73
 
74
+ // Warn when a second copy of Solarite loads into the same page. Each copy has its own classes and its own Globals,
75
+ // so a template or component made by one is not recognised by the other, and the failure that follows (a template
76
+ // rendered as "[object Object]", a slot that stays empty) gives no hint of the cause. The usual ways to get two are
77
+ // importing both Solarite.js and Solarite.min.js, or a JSX runtime file that doesn't match the build being imported.
78
+ // The marker is the same for every build, so the source, debug, and minified builds all detect one another.
79
+ let copy = Symbol.for('solarite');
80
+ if (globalThis[copy])
81
+ console.warn(`Solarite loaded twice: ${globalThis[copy]} and ${import.meta.url}. Templates and components from one won't work in the other.`);
82
+ else
83
+ globalThis[copy] = import.meta.url;
84
+
65
85
  var Globals$1 = Globals;
66
86
 
67
87
  /**
@@ -121,6 +141,12 @@ function isDelvePath(arr) {
121
141
  // d means "don't create"
122
142
  let d = {};
123
143
 
144
+ /**
145
+ * Prefix that asks for a handler to bypass event delegation: `<button native:onclick=\${...}>`
146
+ * is bound with addEventListener at render time, taking its normal place in the browser's own
147
+ * dispatch order. Shared by Util.isEvent() and PathToEvent, which strips it. */
148
+ const nativeEventPrefix = 'native:';
149
+
124
150
  let Util = {
125
151
 
126
152
  /**
@@ -320,7 +346,14 @@ let Util = {
320
346
  return node.value; // String
321
347
  },
322
348
 
349
+ /**
350
+ * True for an attribute name that binds an event: `onclick`, or `native:onclick` for a
351
+ * handler that is registered with addEventListener when the template renders instead of
352
+ * being delegated. Only names an element really exposes as on* handlers count, so an
353
+ * attribute like `online` is never mistaken for one. */
323
354
  isEvent(attribName) {
355
+ if (attribName.startsWith(nativeEventPrefix))
356
+ attribName = attribName.slice(nativeEventPrefix.length);
324
357
  return attribName.startsWith('on') && attribName in Globals$1.div;
325
358
  },
326
359
 
@@ -1190,17 +1223,20 @@ class PathToAttribValue extends Path {
1190
1223
 
1191
1224
  // Delegated path: a bubbling event (when the root's options allow it, the default)
1192
1225
  // stores its handler directly on the node as a per-event-type Symbol expando, with no
1193
- // EventBinding object and no addEventListener call. The root-level dispatcher reads
1194
- // these expandos while walking up from the event target. Re-renders just overwrite
1195
- // the property. this.delegatedKey is set by the PathToEvent constructor only for
1196
- // delegatable event names, so this test also excludes non-bubbling events.
1226
+ // EventBinding object and no addEventListener call. When an event of that type
1227
+ // starts, jitDispatcher() attaches a real listener to each node on its path that
1228
+ // carries the expando, so the browser runs the handler at the node's own turn.
1229
+ // Re-renders just overwrite the property. this.delegatedKey is set by the PathToEvent
1230
+ // constructor only for delegatable event names, so this test also excludes
1231
+ // non-bubbling events and native:on* bindings.
1197
1232
  if (capture === false && this.delegatedKey !== undefined) {
1198
1233
  let opt = this.parentNg.rootNg.renderOptions?.eventDelegation ?? true;
1199
- let toDocument = opt === 'document';
1200
- if (opt !== false && (opt === true || toDocument || opt.includes(eventName))) {
1234
+ // true delegates everything, an array only the events it names, and any other
1235
+ // value (such as the retired 'document' string) counts as true.
1236
+ if (opt !== false && (!Array.isArray(opt) || opt.includes(eventName))) {
1201
1237
  let dk = this.delegatedKey;
1202
1238
  if (node[dk] === undefined) // First binding of this type on this node.
1203
- ensureDelegatedDispatcher(root, eventName, toDocument);
1239
+ ensureDelegatedDispatcher(root, eventName);
1204
1240
  // Array-form bindings (onclick=${[fn, arg]}, the hot per-row case) store the
1205
1241
  // template's own [func, ...args] array; a plain function is stored bare.
1206
1242
  // Either way, nothing is allocated.
@@ -1272,7 +1308,7 @@ function getEventBinding(node, key) {
1272
1308
  return b instanceof EventBinding ? (b.key === key ? b : undefined) : b[key];
1273
1309
  }
1274
1310
 
1275
- // Bubbling events that one root-level listener can dispatch. Same set Solid.js delegates.
1311
+ // Bubbling events the just-in-time dispatcher handles. Same set Solid.js delegates.
1276
1312
  const delegatableEvents = new Set(['beforeinput', 'click', 'contextmenu', 'dblclick', 'focusin', 'focusout',
1277
1313
  'input', 'keydown', 'keyup', 'mousedown', 'mousemove', 'mouseout', 'mouseover', 'mouseup',
1278
1314
  'pointerdown', 'pointermove', 'pointerout', 'pointerover', 'pointerup', 'touchend', 'touchmove', 'touchstart']);
@@ -1296,79 +1332,142 @@ function delegatedKeyFor(eventName) {
1296
1332
  // Exported so NodeGroup.applyStamp()'s compiled stamp program can write it directly.
1297
1333
  const delegatedRootKey = Symbol('solariteDelegatedRoot');
1298
1334
 
1299
- // Per-root-element Set of event types that already have a delegated dispatcher registered.
1335
+ // Set of event types that already have the dispatcher registered, kept on each root element
1336
+ // and on each document.
1300
1337
  const delegatedTypesKey = Symbol('solariteDelegatedTypes');
1301
1338
 
1302
1339
  /**
1303
- * Register the delegated dispatcher for eventName on root if it isn't already.
1304
- * Shared by bindEvent()'s delegated branch and NodeGroup.applyStamp()'s stamp program.
1340
+ * Register the just-in-time dispatcher for eventName on root and on root's document, once
1341
+ * each. Shared by bindEvent()'s delegated branch and NodeGroup.applyStamp()'s stamp program.
1305
1342
  *
1306
- * With andDocument (the eventDelegation:'document' render option), the dispatcher is also
1307
- * registered on the document, once per event type: a bound node that gets re-parented
1308
- * OUTSIDE its root (e.g. a toolbar a dock parks in its own chrome) bubbles past the root's
1309
- * listener, and only a document-level listener can still reach its handler. The
1310
- * delegatedDoneKey marker keeps the two dispatchers from double-running the same event.
1343
+ * Both registrations are needed. The document's listener is what still reaches a bound node
1344
+ * after another component re-parents it outside its root (a toolbar a dock parks in its own
1345
+ * chrome). The root's listener is what reaches what the document cannot see: a component
1346
+ * that isn't in the document at all, nodes inside a closed shadow root, and a synthetic
1347
+ * event dispatched inside any shadow root without composed:true, which never leaves it.
1311
1348
  * @param root {HTMLElement}
1312
- * @param eventName {string}
1313
- * @param andDocument {boolean} */
1314
- function ensureDelegatedDispatcher(root, eventName, andDocument=false) {
1349
+ * @param eventName {string} */
1350
+ function ensureDelegatedDispatcher(root, eventName) {
1315
1351
  let types = root[delegatedTypesKey];
1316
1352
  if (types === undefined)
1317
1353
  types = root[delegatedTypesKey] = new Set();
1318
1354
  if (!types.has(eventName)) {
1319
1355
  types.add(eventName);
1320
- root.addEventListener(eventName, delegatedDispatcher);
1321
- }
1322
- if (andDocument) {
1323
- let doc = root.ownerDocument ?? document;
1356
+ root.addEventListener(eventName, jitDispatcher, true);
1357
+
1358
+ let doc = root.ownerDocument;
1324
1359
  let docTypes = doc[delegatedTypesKey];
1325
1360
  if (docTypes === undefined)
1326
1361
  docTypes = doc[delegatedTypesKey] = new Set();
1327
1362
  if (!docTypes.has(eventName)) {
1328
1363
  docTypes.add(eventName);
1329
- doc.addEventListener(eventName, delegatedDispatcher);
1364
+ doc.addEventListener(eventName, jitDispatcher, true);
1330
1365
  }
1331
1366
  }
1332
1367
  }
1333
1368
 
1334
- // Marks an event the innermost root dispatcher has already walked, so an outer root's
1335
- // listener (when components are nested) skips it instead of dispatching the bindings again.
1369
+ // Set on an event by the first dispatcher to walk it, holding the length of the path it saw,
1370
+ // so the dispatchers on nested roots further down don't repeat the walk. A root inside a
1371
+ // closed shadow root sees a longer path than the document did, because composedPath() hides
1372
+ // a closed tree from listeners outside it, and that mismatch is what makes it walk again.
1336
1373
  const delegatedDoneKey = Symbol('solariteDelegated');
1337
1374
 
1338
1375
  /**
1339
- * The per-root listener for each delegated event type. The first (innermost) root the
1340
- * bubbling event reaches walks from the event target upward, invoking delegated handlers
1341
- * stored on the nodes along the way; outer roots then see the done-marker and skip.
1342
- * Each node carries the root its handlers run with as `this` (see delegatedRootKey), so
1343
- * handlers in an outer component still run with the correct component. event.currentTarget
1344
- * is patched to the node whose handler is running, and restored after. stopPropagation()
1345
- * inside a handler ends the walk, mirroring native bubbling. */
1346
- function delegatedDispatcher(ev) {
1347
- if (ev[delegatedDoneKey])
1348
- return;
1349
- ev[delegatedDoneKey] = true;
1350
- let dk = delegatedKeys[ev.type];
1351
- let current = ev.target;
1352
- Object.defineProperty(ev, 'currentTarget', {configurable: true, get() { return current }});
1353
- while (current) {
1354
- let a = current[dk];
1355
- if (a !== undefined) {
1356
- let root = current[delegatedRootKey];
1357
- if (typeof a === 'function')
1358
- a.call(root, ev, current);
1359
- else
1360
- switch (a.length) {
1361
- case 1: a[0].call(root, ev, current); break;
1362
- case 2: a[0].call(root, a[1], ev, current); break;
1363
- case 3: a[0].call(root, a[1], a[2], ev, current); break;
1364
- default: a[0].call(root, ...a.slice(1), ev, current);
1365
- }
1366
- if (ev.cancelBubble)
1367
- break;
1376
+ * One shared bubble-phase listener per event type, attached to a node only for the duration
1377
+ * of one event. The browser invokes it at the node's own turn in propagation, and it reads
1378
+ * the node's handler THEN rather than when it was attached, so a handler that an earlier
1379
+ * listener in the same dispatch replaced or removed is honored.
1380
+ * @type {Object<string, {handleEvent: function(Event)}>} */
1381
+ const trampolines = {};
1382
+
1383
+ /**
1384
+ * @param type {string}
1385
+ * @return {{handleEvent: function(Event)}} */
1386
+ function trampolineFor(type) {
1387
+ let tramp = trampolines[type];
1388
+ if (tramp === undefined) {
1389
+ let dk = delegatedKeys[type];
1390
+ tramp = trampolines[type] = {
1391
+ // Quoted so the minifier's property mangling doesn't rename it, since the browser looks it up by name.
1392
+ 'handleEvent'(ev) {
1393
+ let node = ev.currentTarget;
1394
+ let a = node[dk];
1395
+ if (a === undefined) // Unbound by an earlier handler in this same dispatch.
1396
+ return;
1397
+ let root = node[delegatedRootKey];
1398
+ if (typeof a === 'function')
1399
+ a.call(root, ev, node);
1400
+ else
1401
+ switch (a.length) {
1402
+ case 1: a[0].call(root, ev, node); break;
1403
+ case 2: a[0].call(root, a[1], ev, node); break;
1404
+ case 3: a[0].call(root, a[1], a[2], ev, node); break;
1405
+ default: a[0].call(root, ...a.slice(1), ev, node);
1406
+ }
1407
+ }
1408
+ };
1409
+ }
1410
+ return tramp;
1411
+ }
1412
+
1413
+ // Nodes still carrying a trampoline, per event type, and the one timer that clears them.
1414
+ const pending = {};
1415
+ let sweepTimer = 0;
1416
+
1417
+ /**
1418
+ * Remove every trampoline attached since the last sweep. Runs as a task, which is always
1419
+ * after every dispatch in progress has finished. A microtask would not be: for a real click
1420
+ * the browser runs a microtask checkpoint between listeners, so a microtask sweep would strip
1421
+ * the trampolines before the event reached the first of them. The sweep is housekeeping
1422
+ * only; a trampoline left in place is harmless, because jitDispatcher() re-attaches it and
1423
+ * the trampoline reads its handler fresh. */
1424
+ function sweep() {
1425
+ sweepTimer = 0;
1426
+ for (let type in pending) {
1427
+ let nodes = pending[type];
1428
+ if (nodes.length !== 0) {
1429
+ pending[type] = [];
1430
+ let tramp = trampolines[type];
1431
+ for (let i=0; i<nodes.length; i++)
1432
+ nodes[i].removeEventListener(type, tramp);
1368
1433
  }
1369
- current = current.parentNode;
1370
1434
  }
1371
- delete ev.currentTarget; // Restore the native getter from the prototype.
1435
+ }
1436
+
1437
+ /**
1438
+ * The capture-phase listener registered per delegated event type on every root and on the
1439
+ * document. It runs before the event reaches anything, walks the event's path, and attaches
1440
+ * the type's trampoline to each node holding a delegated handler. The browser then finishes
1441
+ * the dispatch natively, so those handlers interleave correctly with listeners anyone else
1442
+ * registered, stopPropagation() works in both directions, currentTarget is right, and the
1443
+ * event needn't bubble.
1444
+ *
1445
+ * Each attach removes the trampoline first. One left from an earlier event in this same task
1446
+ * would otherwise keep its old place in the node's listener list, ahead of listeners added
1447
+ * since; removing and re-adding puts it last, so the rule holds without exception: a
1448
+ * delegated handler runs after every listener its element had when the event started. */
1449
+ function jitDispatcher(ev) {
1450
+ let path = ev.composedPath();
1451
+ if (ev[delegatedDoneKey] === path.length)
1452
+ return;
1453
+ ev[delegatedDoneKey] = path.length;
1454
+
1455
+ let type = ev.type;
1456
+ let dk = delegatedKeys[type];
1457
+ let tramp = trampolineFor(type);
1458
+ let list = pending[type];
1459
+ if (list === undefined)
1460
+ list = pending[type] = [];
1461
+ for (let i=0; i<path.length; i++) {
1462
+ let node = path[i];
1463
+ if (node[dk] !== undefined) {
1464
+ node.removeEventListener(type, tramp);
1465
+ node.addEventListener(type, tramp);
1466
+ list.push(node);
1467
+ }
1468
+ }
1469
+ if (list.length !== 0 && sweepTimer === 0)
1470
+ sweepTimer = setTimeout(sweep);
1372
1471
  }
1373
1472
 
1374
1473
  class EventBinding {
@@ -1405,11 +1504,24 @@ class PathToEvent extends PathToAttribValue {
1405
1504
  * Undefined for non-delegatable (non-bubbling) events; bindEvent() then binds directly. */
1406
1505
  delegatedKey;
1407
1506
 
1507
+ /** @type {boolean} True for `native:onclick`: the handler is registered with addEventListener
1508
+ * when the template renders, so it runs at its element's own turn in the browser's dispatch
1509
+ * order instead of being delegated to the component root. */
1510
+ native;
1511
+
1408
1512
  constructor(nodeBefore, nodeMarker, attribName=null, attrValue=null) {
1409
1513
  super(null, nodeMarker, attribName, attrValue);
1410
1514
  this.skipIfSame = true;
1411
- this.eventName = attribName ? attribName.slice(2) : null;
1412
- this.delegatedKey = this.eventName !== null ? delegatedKeyFor(this.eventName) : undefined;
1515
+ let name = attribName;
1516
+ this.native = name !== null && name.startsWith(nativeEventPrefix);
1517
+ if (this.native)
1518
+ name = name.slice(nativeEventPrefix.length);
1519
+ this.eventName = name ? name.slice(2) : null;
1520
+
1521
+ // A native binding leaves delegatedKey undefined. That is the single switch both
1522
+ // bindEvent() and the compiled stamp program test to choose the direct
1523
+ // addEventListener path, so nothing else has to know about the prefix.
1524
+ this.delegatedKey = (this.eventName !== null && !this.native) ? delegatedKeyFor(this.eventName) : undefined;
1413
1525
  }
1414
1526
 
1415
1527
  /**
@@ -3504,106 +3616,120 @@ class PathToComponent extends Path {
3504
3616
  }
3505
3617
  }
3506
3618
 
3507
- // 2. Instantiate component on first time.
3508
- let isAttrib = el.getAttribute('_is');
3509
- if (el.tagName.endsWith('-SOLARITE-PLACEHOLDER') || isAttrib) {
3510
-
3511
-
3512
- // 2a. Instantiate component
3513
- let tagName = (isAttrib || el.tagName.slice(0, -21)).toLowerCase(); // Remove -SOLARITE-PLACEHOLDER
3514
- let Constructor = customElements.get(tagName);
3515
-
3516
- // Not defined yet (e.g. the module is being lazily imported): keep the placeholder
3517
- // and instantiate when the definition lands, like a native custom-element upgrade.
3518
- // deferredExprs always holds the LATEST exprs so re-renders while undefined win.
3519
- if (!Constructor) {
3520
- this.deferredExprs = exprs;
3521
- if (!this.whenDefinedPending) {
3522
- this.whenDefinedPending = true;
3523
- console.warn(`Solarite: <${tagName}> is not defined yet; waiting for customElements.define().`);
3524
- customElements.whenDefined(tagName).then(() => {
3525
- this.whenDefinedPending = false;
3526
- let deferred = this.deferredExprs;
3527
- this.deferredExprs = null;
3528
- // Skip if a newer render already instantiated or replaced the placeholder.
3529
- if (deferred && this.nodeMarker === el && el.tagName.endsWith('-SOLARITE-PLACEHOLDER'))
3530
- this.applyAll(deferred);
3531
- });
3619
+ // Constructing a component runs arbitrary user code -- field initializers, the
3620
+ // constructor body, render() -- and that code can build more components, re-entering
3621
+ // this method and overwriting the hand-off parked below. Saving the caller's value
3622
+ // here and restoring it in the finally makes the JS call stack the stack this hand-off
3623
+ // needs, and unlike an explicit stack it cannot leak if construction throws.
3624
+ let prevSlotChildren = Globals$1.currentSlotChildren;
3625
+ try {
3626
+ // 2. Instantiate component on first time.
3627
+ let isAttrib = el.getAttribute('_is');
3628
+ if (el.tagName.endsWith('-SOLARITE-PLACEHOLDER') || isAttrib) {
3629
+
3630
+
3631
+ // 2a. Instantiate component
3632
+ let tagName = (isAttrib || el.tagName.slice(0, -21)).toLowerCase(); // Remove -SOLARITE-PLACEHOLDER
3633
+ let Constructor = customElements.get(tagName);
3634
+
3635
+ // Not defined yet (e.g. the module is being lazily imported): keep the placeholder
3636
+ // and instantiate when the definition lands, like a native custom-element upgrade.
3637
+ // deferredExprs always holds the LATEST exprs so re-renders while undefined win.
3638
+ if (!Constructor) {
3639
+ this.deferredExprs = exprs;
3640
+ if (!this.whenDefinedPending) {
3641
+ this.whenDefinedPending = true;
3642
+ console.warn(`Solarite: <${tagName}> is not defined yet; waiting for customElements.define().`);
3643
+ customElements.whenDefined(tagName).then(() => {
3644
+ this.whenDefinedPending = false;
3645
+ let deferred = this.deferredExprs;
3646
+ this.deferredExprs = null;
3647
+ // Skip if a newer render already instantiated or replaced the placeholder.
3648
+ if (deferred && this.nodeMarker === el && el.tagName.endsWith('-SOLARITE-PLACEHOLDER'))
3649
+ this.applyAll(deferred);
3650
+ });
3651
+ }
3652
+ return;
3532
3653
  }
3533
- Globals$1.currentSlotChildren = null;
3534
- return;
3535
- }
3536
3654
 
3537
- Globals$1.currentSlotChildren = [...el.childNodes]; // TODO: Does this need to be a stack?
3538
- let newEl = new Constructor(attribs);
3655
+ // Hand the children declared inside the component's tag to the RootNodeGroup that
3656
+ // its render() is about to create. There is no other channel: the children have
3657
+ // to be parked before new Constructor(), because a Solarite constructor may call
3658
+ // this.render() itself, and the element that would otherwise carry them does not
3659
+ // exist yet.
3660
+ Globals$1.currentSlotChildren = {Constructor, nodes: [...el.childNodes]};
3661
+ let newEl = new Constructor(attribs);
3662
+
3663
+ // 2b. Copy attributes over.
3664
+ if (isAttrib) {
3665
+ newEl.setAttribute('is', isAttrib);
3666
+ // el.removeAttribute('_is');
3667
+ }
3668
+ for (let attrib of el.attributes)
3669
+ if (attrib.name !== '_is')
3670
+ newEl.setAttribute(attrib.name, attrib.value);
3539
3671
 
3540
- // 2b. Copy attributes over.
3541
- if (isAttrib) {
3542
- newEl.setAttribute('is', isAttrib);
3543
- // el.removeAttribute('_is');
3544
- }
3545
- for (let attrib of el.attributes)
3546
- if (attrib.name !== '_is')
3547
- newEl.setAttribute(attrib.name, attrib.value);
3548
-
3549
- // Set dynamic attributes if they are primitive types.
3550
- for (let name in attribs) {
3551
- let val = attribs[name];
3552
- let valType = typeof val;
3553
- // Only true and false can reach here, so the undefined/null halves of the
3554
- // falsy test this used to spell out could never have decided anything.
3555
- if (valType === 'boolean') {
3556
- if (val)
3557
- newEl.setAttribute(name, '');
3672
+ // Set dynamic attributes if they are primitive types.
3673
+ for (let name in attribs) {
3674
+ let val = attribs[name];
3675
+ let valType = typeof val;
3676
+ // Only true and false can reach here, so the undefined/null halves of the
3677
+ // falsy test this used to spell out could never have decided anything.
3678
+ if (valType === 'boolean') {
3679
+ if (val)
3680
+ newEl.setAttribute(name, '');
3681
+ }
3682
+
3683
+ // If type is a non-boolean primitive, set the attribute value.
3684
+ else if (valType==='string' || valType === 'number' || valType==='bigint')
3685
+ newEl.setAttribute(name, val);
3558
3686
  }
3559
3687
 
3560
- // If type is a non-boolean primitive, set the attribute value.
3561
- else if (valType==='string' || valType === 'number' || valType==='bigint')
3562
- newEl.setAttribute(name, val);
3563
- }
3564
3688
 
3689
+ // 2c. If an id pointed at the placeholder, update it to point to the new element.
3690
+ let id = newEl.getAttribute('data-id') || newEl.getAttribute('id');
3691
+ if (id)
3692
+ delve(this.parentNg.getRootEl(), id.split(/\./g), newEl);
3565
3693
 
3566
- // 2c. If an id pointed at the placeholder, update it to point to the new element.
3567
- let id = newEl.getAttribute('data-id') || newEl.getAttribute('id');
3568
- if (id)
3569
- delve(this.parentNg.getRootEl(), id.split(/\./g), newEl);
3694
+ // 2d. Update paths to use replaced element.
3695
+ let ng = this.parentNg;
3696
+ this.nodeMarker = newEl;
3697
+ for (let path of ng.paths) {
3698
+ if (path.nodeMarker === el)
3699
+ path.nodeMarker = newEl;
3700
+ if (path.nodeBefore === el)
3701
+ path.nodeBefore = newEl;
3702
+ }
3703
+ if (ng.startNode === el)
3704
+ ng.startNode = newEl;
3705
+ if (ng.endNode === el)
3706
+ ng.endNode = newEl;
3707
+
3708
+ // 2f. Call render() if it wasn't called by the constructor.
3709
+ // This must happen before we add it to the DOM which can trigger connectedCallback() -> renderFirstTime()
3710
+ // Because that path renders it without the attribute expressions.
3711
+ if (typeof newEl.render === 'function' && !Globals$1.rendered.has(newEl))
3712
+ newEl.render(attribs, true);
3713
+
3714
+ // 2g. Update attribute paths to use the new element and re-apply them.
3715
+ for (let i=0, attribPath; attribPath = this.attribPaths[i]; i++) {
3716
+ attribPath.parentNg = this.parentNg;
3717
+ attribPath.nodeMarker = newEl;
3718
+ attribPath.applyAll(exprs[i]);
3719
+ }
3570
3720
 
3571
- // 2d. Update paths to use replaced element.
3572
- let ng = this.parentNg;
3573
- this.nodeMarker = newEl;
3574
- for (let path of ng.paths) {
3575
- if (path.nodeMarker === el)
3576
- path.nodeMarker = newEl;
3577
- if (path.nodeBefore === el)
3578
- path.nodeBefore = newEl;
3579
- }
3580
- if (ng.startNode === el)
3581
- ng.startNode = newEl;
3582
- if (ng.endNode === el)
3583
- ng.endNode = newEl;
3584
-
3585
- // 2f. Call render() if it wasn't called by the constructor.
3586
- // This must happen before we add it to the DOM which can trigger connectedCallback() -> renderFirstTime()
3587
- // Because that path renders it without the attribute expressions.
3588
- if (typeof newEl.render === 'function' && !Globals$1.rendered.has(newEl))
3589
- newEl.render(attribs, true);
3590
-
3591
- // 2g. Update attribute paths to use the new element and re-apply them.
3592
- for (let i=0, attribPath; attribPath = this.attribPaths[i]; i++) {
3593
- attribPath.parentNg = this.parentNg;
3594
- attribPath.nodeMarker = newEl;
3595
- attribPath.applyAll(exprs[i]);
3721
+ // 2e. Swap it to the DOM.
3722
+ el.replaceWith(newEl);
3596
3723
  }
3597
3724
 
3598
- // 2e. Swap it to the DOM.
3599
- el.replaceWith(newEl);
3600
- }
3601
-
3602
- // 2f. Render
3603
- else if (typeof el.render === 'function')
3604
- el.render(attribs, changed);
3725
+ // 2f. Render
3726
+ else if (typeof el.render === 'function')
3727
+ el.render(attribs, changed);
3605
3728
 
3606
- Globals$1.currentSlotChildren = null;
3729
+ }
3730
+ finally {
3731
+ Globals$1.currentSlotChildren = prevSlotChildren;
3732
+ }
3607
3733
  }
3608
3734
 
3609
3735
  /**
@@ -3792,6 +3918,9 @@ class Shell {
3792
3918
  // Smaller fragments make cloning, path resolution, and insertion faster.
3793
3919
  stripTableWhitespace(this.docFrag);
3794
3920
 
3921
+ // 1c. Neutralize `is` so the browser can't upgrade a placeholder out from under us.
3922
+ renameIsAttribs(this.docFrag);
3923
+
3795
3924
  // 2. Find placeholders
3796
3925
  let node;
3797
3926
  let toRemove = [];
@@ -3805,7 +3934,7 @@ class Shell {
3805
3934
 
3806
3935
  // Replace attributes
3807
3936
  if (node.nodeType === 1) {
3808
- const hasIs = node.hasAttribute('is');
3937
+ const hasIs = node.hasAttribute('_is'); // Renamed from `is` in step 1c.
3809
3938
  const isComponent = (hasIs || node.tagName.includes('-'));
3810
3939
  const componentAttribPaths = [];
3811
3940
 
@@ -3909,10 +4038,6 @@ class Shell {
3909
4038
  path.attribPaths = componentAttribPaths;
3910
4039
  this.paths.splice(this.paths.length - componentAttribPaths.length, 0, path); // Insert before its componentAttribPaths
3911
4040
 
3912
- if (hasIs) {
3913
- node.setAttribute('_is', node.getAttribute('is'));
3914
- node.removeAttribute('is');
3915
- }
3916
4041
  }
3917
4042
  }
3918
4043
 
@@ -3929,7 +4054,7 @@ class Shell {
3929
4054
  // Components and slots are excluded because they move their children
3930
4055
  // during instantiation, which would orphan the expression's region.
3931
4056
  if (parent.nodeType === 1 && !node.previousSibling && !node.nextSibling
3932
- && !parent.tagName.includes('-') && parent.tagName !== 'SLOT' && !parent.hasAttribute('is')) {
4057
+ && !parent.tagName.includes('-') && parent.tagName !== 'SLOT' && !parent.hasAttribute('_is')) {
3933
4058
  let path = new PathToNodes(null, parent);
3934
4059
  path.wholeParent = true;
3935
4060
  this.paths.push(path);
@@ -4343,6 +4468,38 @@ function stripTableWhitespace(el) {
4343
4468
  }
4344
4469
  }
4345
4470
 
4471
+ /**
4472
+ * Rename every `is` attribute to `_is`, rebuilding the element to do it.
4473
+ *
4474
+ * A component written as a dashed tag is neutralized in the shell by renaming the TAG
4475
+ * (`<my-tag>` becomes `<my-tag-SOLARITE-PLACEHOLDER>`), so the browser never recognizes the
4476
+ * placeholder and never upgrades it. A customized built-in cannot be neutralized that way,
4477
+ * because its tag has to stay real: a `<tr is="my-row">` that is not a `<tr>` is thrown out
4478
+ * by the parser's table rules. So its ATTRIBUTE is renamed instead.
4479
+ *
4480
+ * Renaming the attribute in place is not enough. `is` is also recorded in an internal slot on
4481
+ * the element, which removeAttribute() cannot clear and cloneNode() copies, so a placeholder
4482
+ * that was parsed with `is` stays a customized built-in as far as the browser is concerned.
4483
+ * Every clone of it is upgraded the moment it enters a document with a browsing context —
4484
+ * running the component's constructor on the placeholder, before PathToComponent has
4485
+ * instantiated the real element or evaluated the attribute expressions meant for it. A
4486
+ * constructor that renders then renders the placeholder, whose children are the ones the user
4487
+ * declared, and those get handed to the real instance as if they were slot content.
4488
+ *
4489
+ * Building a fresh element and moving everything across is the only way to drop that slot.
4490
+ * It happens once per unique template, because Shells are cached, and never per render.
4491
+ *
4492
+ * @param docFrag {DocumentFragment} */
4493
+ function renameIsAttribs(docFrag) {
4494
+ for (let el of docFrag.querySelectorAll('[is]')) {
4495
+ let clean = el.ownerDocument.createElement(el.tagName);
4496
+ for (let attrib of el.attributes)
4497
+ clean.setAttribute(attrib.name === 'is' ? '_is' : attrib.name, attrib.value);
4498
+ clean.append(...el.childNodes);
4499
+ el.replaceWith(clean);
4500
+ }
4501
+ }
4502
+
4346
4503
  // One-entry memo for Shell.get().
4347
4504
  let lastHtmlStrings = null, lastSvgMode = false, lastShell = null;
4348
4505
 
@@ -4672,17 +4829,17 @@ class NodeGroup {
4672
4829
  let stampers = shell.stampPaths;
4673
4830
  let rootNg = this.rootNg;
4674
4831
  let root = rootNg.rootEl;
4832
+ // Any value other than false or an array of event names means delegate everything.
4675
4833
  let opt = rootNg.renderOptions?.eventDelegation;
4676
- let delegateDoc = opt === 'document';
4677
- let delegateAll = opt === undefined || opt === true || delegateDoc;
4834
+ let delegateAll = opt !== false && !Array.isArray(opt);
4678
4835
 
4679
4836
  // Register this shell's delegated dispatchers once for a whole run of rows. They live on
4680
- // the root, not on the bound nodes, so asking per node — as the general binding path has
4681
- // to — would be a call and a set lookup for every handler in the list.
4837
+ // the root and the document, not on the bound nodes, so asking per node — as the general
4838
+ // binding path has to — would be a call and a set lookup for every handler in the list.
4682
4839
  let names = shell.stampEventNames;
4683
4840
  if (names !== null && delegateAll && rootNg[lastStampedShellKey] !== shell) {
4684
4841
  for (let k=0; k<names.length; k++)
4685
- ensureDelegatedDispatcher(root, names[k], delegateDoc);
4842
+ ensureDelegatedDispatcher(root, names[k]);
4686
4843
  rootNg[lastStampedShellKey] = shell;
4687
4844
  }
4688
4845
 
@@ -5075,13 +5232,30 @@ class RootNodeGroup extends NodeGroup {
5075
5232
  if (el) {
5076
5233
  this.rootEl = el;
5077
5234
 
5078
- // Save slot
5079
- // 1. Globals.currentSlotChildren is set if this is called via PathToComponent.applyComponent() calls render()
5080
- // 2. el.childNodes is set if render() is called manually for the first time.
5235
+ // Save the children that belong in this component's <slot>, from one of two places:
5236
+ // 1. A hand-off parked by PathToComponent.applyAll() just before it constructed
5237
+ // us, when this component was declared inside another template. It carries
5238
+ // the Constructor it was meant for, so an unrelated component built in the
5239
+ // meantime -- a field initializer creating a menu, say -- leaves it alone.
5240
+ // 2. el.childNodes, when render() is called manually for the first time.
5241
+ // An addressed hand-off wins even when its node list is empty: a component
5242
+ // declared as <my-tag></my-tag> is asking for an empty slot, not for whatever
5243
+ // its own constructor happened to put in the element.
5244
+ //
5245
+ // The hand-off is deliberately NOT cleared on read. A component that builds
5246
+ // another instance of its OWN class while constructing cannot be told apart
5247
+ // from itself by any address, so both match; the inner one takes the nodes and
5248
+ // this outer one takes them straight back, which is the only thing that makes
5249
+ // that case work.
5250
+ let handOff = Globals$1.currentSlotChildren;
5251
+ let mySlotNodes = handOff?.Constructor === el.constructor
5252
+ ? handOff.nodes
5253
+ : (el.childNodes.length ? [...el.childNodes] : null);
5254
+
5081
5255
  let slotChildren;
5082
- if (Globals$1.currentSlotChildren || el.childNodes.length) {
5256
+ if (mySlotNodes) {
5083
5257
  slotChildren = Globals$1.doc.createDocumentFragment();
5084
- slotChildren.append(...(Globals$1.currentSlotChildren || el.childNodes));
5258
+ slotChildren.append(...mySlotNodes);
5085
5259
  }
5086
5260
 
5087
5261
  // If el should replace the root node of the fragment.