@webjsdev/core 0.7.39 → 0.7.40

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/src/slot.js CHANGED
@@ -1,13 +1,35 @@
1
1
  /**
2
- * Light-DOM <slot> runtime for @webjsdev/core.
2
+ * Light-DOM <slot> runtime for @webjsdev/core: CHILDREN AS VALUES (#1015).
3
3
  *
4
4
  * Provides functional parity with shadow-DOM <slot> projection inside
5
- * light-DOM WebComponents (those with static shadow = false). The framework
6
- * physically projects authored children into <slot> elements at render
7
- * time and fires slotchange events. The DOM API surface (assignedNodes,
8
- * assignedElements, assignedSlot) is polyfilled on HTMLSlotElement and
9
- * Element prototypes so user code reads the same against light-DOM slots
10
- * as it does against native shadow-DOM slots.
5
+ * light-DOM WebComponents (those with static shadow = false), on the React
6
+ * children model: authored children are captured ONCE per host lifetime into
7
+ * a per-host slot record (name -> Node[]), and the component's own renderer
8
+ * places that content into its `<slot data-webjs-light>` containers as an
9
+ * ordinary render-owned value. ONE renderer owns all nodes.
10
+ *
11
+ * What this deliberately is NOT (the pre-#1015 model, deleted): an
12
+ * observer-driven projection runtime. There are no MutationObservers, no
13
+ * microtask projection scheduler, no framework-marker record sniffing, and no
14
+ * pending-children shuffling. Those made light-DOM slots a third DOM-mutating
15
+ * actor beside the renderer and the client router, which is exactly the
16
+ * ownership overlap behind the #906/#908/#912/#914 cascade and the #1006
17
+ * non-idempotent projection class. Capture-once closes #1006 by construction:
18
+ * there is no second capture that could misclassify rendered nodes as
19
+ * authored children.
20
+ *
21
+ * SEMANTICS CHANGE (deliberate, breaking): an external `appendChild` on a
22
+ * mounted host, or a `slot=""`/`name=""` attribute flip, no longer live
23
+ * re-projects. The dynamic path is the public API:
24
+ *
25
+ * host.slots read view of the record ({ default, [name] })
26
+ * host.hasSlot(name) does the record carry content for `name`
27
+ * host.setSlotContent(name, value) replace a slot's content (Node,
28
+ * Node[], string, or null/[] to reset to fallback)
29
+ *
30
+ * The READS survive as derived shims: `assignedNodes()`,
31
+ * `assignedElements()`, `assignedSlot`, and `slotchange` all keep working
32
+ * against light-DOM slots exactly as before.
11
33
  *
12
34
  * Polyfill safety. Every prototype patch checks for the `data-webjs-light`
13
35
  * attribute on the slot element and falls through to the saved native
@@ -19,66 +41,12 @@
19
41
  * loads slot.js without blowing up. Server-side slot substitution lives
20
42
  * in render-server.js (injectDSD); slot.js drives the client runtime
21
43
  * only.
22
- *
23
- * Cross-file coordination. Two pieces of behaviour live partly here and
24
- * partly in render-client.js:
25
- * 1. Fallback content restoration. When a slot transitions to
26
- * data-projection="fallback", slot.js clears the actual-assigned
27
- * children; the slot-part's apply step in render-client.js
28
- * restores the compiled fallback template into the slot.
29
- * 2. Slot-part teardown (slot inside a conditional that collapsed).
30
- * render-client.js calls movePendingFromTorndownSlot() before
31
- * removing the slot from the DOM so its assigned children survive
32
- * to be re-projected on the next render.
33
44
  */
34
45
 
35
- import { MARKER } from './html.js';
36
-
37
46
  // ---------------------------------------------------------------------------
38
47
  // Module-scope constants
39
48
  // ---------------------------------------------------------------------------
40
49
 
41
- // Comment-node values used by render-client.js to bookend every
42
- // rendered template instance. Used to distinguish framework-driven
43
- // childList mutations from user-driven appendChild / removeChild in
44
- // the host's MutationObserver below.
45
- const FRAMEWORK_MARKER_START = `${MARKER}s`;
46
- const FRAMEWORK_MARKER_END = `${MARKER}e`;
47
-
48
- /**
49
- * True when the node is one of the framework's render-instance bookend
50
- * comment markers. The render-client.js paths that insert into or
51
- * remove from a slot host always include such a marker in the same
52
- * MutationRecord (either by `replaceChildren(start, ...content, end)`
53
- * at the top level or by `insertBefore(frag, marker)` where `frag`
54
- * itself is built from a `nodesToFrag([start, ...content, end])`).
55
- * Mutation records that touch one of these markers therefore belong
56
- * to the framework's render commit, not user authoring.
57
- *
58
- * @param {Node} node
59
- * @returns {boolean}
60
- */
61
- function isFrameworkMarker(node) {
62
- if (!node || node.nodeType !== 8 /* COMMENT_NODE */) return false;
63
- const v = node.nodeValue;
64
- return v === FRAMEWORK_MARKER_START || v === FRAMEWORK_MARKER_END;
65
- }
66
-
67
- /**
68
- * True when a MutationRecord's added or removed nodes include a
69
- * framework bookend marker. Used by the host's childList observer
70
- * to drop renderer-driven records that would otherwise be
71
- * misinterpreted as authored-child changes.
72
- *
73
- * @param {MutationRecord} record
74
- * @returns {boolean}
75
- */
76
- function isFrameworkRecord(record) {
77
- for (const n of record.addedNodes) if (isFrameworkMarker(n)) return true;
78
- for (const n of record.removedNodes) if (isFrameworkMarker(n)) return true;
79
- return false;
80
- }
81
-
82
50
  function detectBrowser() {
83
51
  return typeof HTMLElement !== 'undefined' && typeof HTMLSlotElement !== 'undefined';
84
52
  }
@@ -103,8 +71,8 @@ export const PROJECTION_FALLBACK = 'fallback';
103
71
  /**
104
72
  * Symbol-keyed property on a slot element that holds a DocumentFragment
105
73
  * containing the slot's fallback content (cloned from the compiled template
106
- * by render-client.js at slot-part bind time). slot.js swaps these nodes
107
- * into and out of the slot as the projection state toggles between
74
+ * by render-client.js at slot-part bind time). The apply step swaps these
75
+ * nodes into and out of the slot as the projection state toggles between
108
76
  * "actual" and "fallback".
109
77
  */
110
78
  export const SLOT_FALLBACK_FRAG = Symbol('webjs.slot.fallbackFrag');
@@ -198,7 +166,11 @@ function isInShadowRoot(node) {
198
166
  const parent = n.parentNode;
199
167
  if (!parent) return false;
200
168
  if (parent === n) return false;
201
- if (/** @type {any} */ (parent).host) return true;
169
+ // A real ShadowRoot is a DocumentFragment (nodeType 11) exposing its
170
+ // owner as `.host`. `.host` truthiness ALONE misfires on ordinary
171
+ // elements: HTMLAnchorElement/HTMLAreaElement expose a URL-derived
172
+ // `.host`, so a slot inside an <a> card read as "in shadow DOM".
173
+ if (parent.nodeType === 11 && /** @type {any} */ (parent).host) return true;
202
174
  n = parent;
203
175
  }
204
176
  return false;
@@ -282,24 +254,16 @@ function findLightAssignedSlot(el) {
282
254
  }
283
255
 
284
256
  // ---------------------------------------------------------------------------
285
- // Per-host state
257
+ // Per-host state: the slot record
286
258
  // ---------------------------------------------------------------------------
287
259
 
288
260
  /**
289
261
  * @typedef {Object} SlotState
290
- * @property {Map<string|null, Node[]>} assignedByName Captured authored
291
- * children per slot name (null is the default slot).
292
- * @property {Map<string|null, Node[]>} pendingByName Children awaiting a
293
- * slot to be (re-)created. Populated when a slot is torn down by a
294
- * conditional collapse or removed from the render tree.
262
+ * @property {Map<string|null, Node[]>} assignedByName The slot record:
263
+ * captured (or programmatically set) content per slot name (null is the
264
+ * default slot). The single source of truth for what each slot shows.
295
265
  * @property {WeakMap<HTMLSlotElement, Node[]>} lastSnapshot Per-slot
296
266
  * record of the previous assigned-node set for slotchange equality.
297
- * @property {WeakMap<HTMLSlotElement, MutationObserver>} nameObservers
298
- * Per-slot `name` attribute observers.
299
- * @property {MutationObserver | null} childObserver Host's childList +
300
- * child `slot` attribute observer.
301
- * @property {boolean} scheduled Microtask flag for batched projection.
302
- * @property {Set<HTMLSlotElement>} ownedSlots Slots we currently track.
303
267
  */
304
268
 
305
269
  /**
@@ -315,12 +279,7 @@ export function ensureSlotState(host) {
315
279
  if (!state) {
316
280
  state = {
317
281
  assignedByName: new Map(),
318
- pendingByName: new Map(),
319
282
  lastSnapshot: new WeakMap(),
320
- nameObservers: new WeakMap(),
321
- childObserver: null,
322
- scheduled: false,
323
- ownedSlots: new Set(),
324
283
  };
325
284
  h[SLOT_STATE] = state;
326
285
  }
@@ -333,14 +292,16 @@ export function hasSlotState(host) {
333
292
  }
334
293
 
335
294
  // ---------------------------------------------------------------------------
336
- // Authored-child capture
295
+ // Capture: once per host lifetime
337
296
  // ---------------------------------------------------------------------------
338
297
 
339
298
  /**
340
- * Move every authored child of `host` into the slot state, partitioning
299
+ * Move every authored child of `host` into the slot record, partitioning
341
300
  * by each child's `slot=""` attribute. After this runs, `host` has no
342
- * children; the framework re-inserts them at projection time inside the
343
- * correct <slot> elements. Idempotent.
301
+ * children; the renderer re-inserts them at slot-apply time inside the
302
+ * correct <slot> elements. Runs ONCE per host lifetime (first mount, no
303
+ * SSR); there is no later re-capture, so rendered nodes can never be
304
+ * misclassified as authored children (#1006, closed by construction).
344
305
  *
345
306
  * @param {Element} host
346
307
  */
@@ -357,7 +318,8 @@ export function captureAuthoredChildren(host) {
357
318
  /**
358
319
  * After SSR + hydration, projected children already live inside their
359
320
  * <slot data-webjs-light> elements. Walk the host's render tree and
360
- * record those existing assignments in the state without moving DOM.
321
+ * record those existing assignments in the record without moving DOM.
322
+ * The capture-once counterpart for the hydration path.
361
323
  *
362
324
  * @param {Element} host
363
325
  */
@@ -368,7 +330,7 @@ export function adoptSSRAssignments(host) {
368
330
  /** @type {HTMLSlotElement} */
369
331
  const s = /** @type {any} */ (slot);
370
332
  if (s.getAttribute(PROJECTION_ATTR) !== PROJECTION_ACTUAL) continue;
371
- const name = s.getAttribute('name') || null;
333
+ const name = keyOfName(s.getAttribute('name'));
372
334
  if (!state.assignedByName.has(name)) {
373
335
  const children = Array.from(s.childNodes);
374
336
  state.assignedByName.set(name, children);
@@ -377,6 +339,21 @@ export function adoptSSRAssignments(host) {
377
339
  }
378
340
  }
379
341
 
342
+ /**
343
+ * Normalise a slot name to the record key, applied at EVERY name read
344
+ * (capture, adopt, application, rescue, and the public API) so the record
345
+ * key is uniform end to end. The default slot is stored under `null`;
346
+ * `''` and `'default'` are aliases for it. Consequence: `default` is a
347
+ * RESERVED slot name (a literal `name="default"` slot addresses the
348
+ * default slot); the SSR substitution applies the same rule.
349
+ *
350
+ * @param {string | null | undefined} name
351
+ * @returns {string | null}
352
+ */
353
+ function keyOfName(name) {
354
+ return name == null || name === '' || name === 'default' ? null : name;
355
+ }
356
+
380
357
  /**
381
358
  * Read the slot="" attribute on an element child. Text and comment nodes
382
359
  * always route to the default slot (key = null).
@@ -387,8 +364,7 @@ export function adoptSSRAssignments(host) {
387
364
  function slotNameOf(node) {
388
365
  if (node.nodeType !== 1) return null;
389
366
  const el = /** @type {Element} */ (node);
390
- const v = el.getAttribute('slot');
391
- return v ? v : null;
367
+ return keyOfName(el.getAttribute('slot'));
392
368
  }
393
369
 
394
370
  /** Append a value to a Map<K, V[]>, creating the array on first hit. */
@@ -401,235 +377,122 @@ function appendToMap(map, key, value) {
401
377
  arr.push(value);
402
378
  }
403
379
 
404
- /** Append a list of values to a Map<K, V[]>. */
405
- function appendArrayToMap(map, key, values) {
406
- if (!values.length) return;
407
- let arr = map.get(key);
408
- if (!arr) {
409
- arr = [];
410
- map.set(key, arr);
411
- }
412
- for (const v of values) arr.push(v);
413
- }
414
-
415
380
  // ---------------------------------------------------------------------------
416
- // Observers
381
+ // The public children-as-values API (#1015)
417
382
  // ---------------------------------------------------------------------------
418
383
 
419
384
  /**
420
- * Attach mutation observers to a host. Watches:
421
- * 1. Host's childList for authored children added or removed at runtime.
422
- * 2. Host's children's `slot` attribute for children moving between slots.
423
- *
424
- * Slot-name observers are attached separately by projectChildren when it
425
- * discovers a new slot element.
385
+ * A read view of the host's slot record: `{ default: Node[], [name]: Node[] }`.
386
+ * Fresh arrays each call, so callers cannot corrupt the record. Enables
387
+ * conditional-on-slot rendering (`this.slots.header ? ... : ...`).
426
388
  *
427
389
  * @param {Element} host
390
+ * @returns {Record<string, Node[]>}
428
391
  */
429
- export function attachSlotObservers(host) {
430
- if (!inBrowser) return;
431
- const state = ensureSlotState(host);
432
- if (state.childObserver) return;
433
- state.childObserver = new MutationObserver((records) => {
434
- let dirty = false;
435
- for (const r of records) {
436
- // Skip records produced by the framework's own renderer. Each
437
- // top-level TemplateInstance commit (`createInstance`) and each
438
- // child-part template swap (`applyChildPart`) inserts and removes
439
- // its content as a unit bounded by `wjm-s`/`wjm-e` comment markers
440
- // (see render-client.js). When such a record reaches the host's
441
- // childList observer, treating its addedNodes as authored
442
- // children pollutes assignedByName with the renderer's own
443
- // wrapper elements; projectChildren then tries to append those
444
- // wrappers into a <slot> they contain, raising a
445
- // HierarchyRequestError ("new child element contains the
446
- // parent"). Filtering on marker presence isolates renderer
447
- // commits from real authoring (which never inserts framework
448
- // markers).
449
- if (isFrameworkRecord(r)) continue;
450
- if (r.type === 'childList') {
451
- for (const node of r.addedNodes) {
452
- // A new child appeared directly under host (not via slot.append
453
- // inside our own projection routine, which appends deep inside
454
- // the rendered template and isn't visible with subtree:false).
455
- // Record it in the assignment table and let projection move it.
456
- if (node.parentElement === host) {
457
- appendToMap(state.assignedByName, slotNameOf(node), node);
458
- dirty = true;
459
- }
460
- }
461
- for (const node of r.removedNodes) {
462
- // Skip when the node is still inside the host's subtree. That
463
- // means projection moved it from host's direct children into
464
- // a slot deeper down; the assignment table should keep the
465
- // entry. Only treat as a user-removal when the node truly left.
466
- if (host.contains(node)) continue;
467
- if (removeFromAssignments(state, node)) dirty = true;
468
- }
469
- } else if (r.type === 'attributes' && r.attributeName === 'slot') {
470
- const target = /** @type {Element} */ (r.target);
471
- // A child's slot=" " attribute changed. If it's an authored
472
- // child currently in the assignment table (either still a
473
- // direct child of host or already projected into a slot deeper
474
- // down), re-partition by the new name.
475
- if (removeFromAssignments(state, target)) {
476
- appendToMap(state.assignedByName, slotNameOf(target), target);
477
- dirty = true;
478
- } else if (target.parentElement === host) {
479
- // Edge case where state.removeFromAssignments missed it: still
480
- // direct on host and not yet captured.
481
- appendToMap(state.assignedByName, slotNameOf(target), target);
482
- dirty = true;
483
- }
484
- }
485
- }
486
- if (dirty) scheduleProjection(host);
487
- });
488
- // subtree: true so children moved into <slot> deeper in the tree are
489
- // still observed when their slot=" " attribute changes (which would
490
- // otherwise be invisible under subtree: false). The attribute filter
491
- // keeps the observed surface small. childList stays scoped to host's
492
- // direct children: appending a node to host is the user-level entry
493
- // point for authoring; projection's slot.appendChild is on a node
494
- // deeper in the tree and the filter does not fire on those.
495
- state.childObserver.observe(host, {
496
- childList: true,
497
- attributes: true,
498
- attributeFilter: ['slot'],
499
- subtree: true,
500
- });
392
+ export function slotsView(host) {
393
+ /** @type {Record<string, Node[]>} */
394
+ const view = {};
395
+ const state = /** @type {SlotState | undefined} */ (/** @type {any} */ (host)[SLOT_STATE]);
396
+ if (!state) return view;
397
+ for (const [name, nodes] of state.assignedByName) {
398
+ view[name == null ? 'default' : name] = nodes.slice();
399
+ }
400
+ return view;
501
401
  }
502
402
 
503
403
  /**
504
- * Detach the host's child observer and every slot-name observer. Called
505
- * from disconnectedCallback. The state itself is preserved so that a
506
- * subsequent reconnection picks up where it left off.
404
+ * Does the host's slot record carry content for `name`?
507
405
  *
508
406
  * @param {Element} host
407
+ * @param {string | null} [name]
408
+ * @returns {boolean}
509
409
  */
510
- export function detachSlotObservers(host) {
511
- /** @type {SlotState | undefined} */
512
- const state = /** @type {any} */ (host)[SLOT_STATE];
513
- if (!state) return;
514
- if (state.childObserver) {
515
- state.childObserver.disconnect();
516
- state.childObserver = null;
517
- }
518
- for (const slot of state.ownedSlots) {
519
- const obs = state.nameObservers.get(slot);
520
- if (obs) {
521
- obs.disconnect();
522
- state.nameObservers.delete(slot);
523
- }
524
- }
525
- }
526
-
527
- /** Remove a node from the assignedByName map. Returns true if removed. */
528
- function removeFromAssignments(state, node) {
529
- for (const [key, arr] of state.assignedByName) {
530
- const idx = arr.indexOf(node);
531
- if (idx !== -1) {
532
- arr.splice(idx, 1);
533
- if (arr.length === 0) state.assignedByName.delete(key);
534
- return true;
535
- }
536
- }
537
- return false;
410
+ export function hasSlotContent(host, name) {
411
+ const state = /** @type {SlotState | undefined} */ (/** @type {any} */ (host)[SLOT_STATE]);
412
+ if (!state) return false;
413
+ const arr = state.assignedByName.get(keyOfName(name));
414
+ return Boolean(arr && arr.length);
538
415
  }
539
416
 
540
417
  /**
541
- * True when `node` is currently slotted inside one of the host's owned
542
- * slots. Used to decide whether a `slot` attribute mutation on an
543
- * already-projected node should trigger a re-projection.
418
+ * Replace a slot's content (#1015): THE dynamic path for slotted children.
419
+ * Updates the record, re-applies the host's slot assignments, and fires
420
+ * `slotchange` on any slot whose assignment changed. Accepts a Node, a
421
+ * Node[], a string (becomes a Text node), or null/[] to clear the slot
422
+ * back to its fallback content.
423
+ *
424
+ * This replaces the deleted live re-projection observers: an external
425
+ * `appendChild` or `slot=""` flip on a mounted host is inert by design;
426
+ * the owner of dynamic slot content calls this API instead (the client
427
+ * router does exactly that during a same-route morph).
428
+ *
429
+ * @param {Element} host
430
+ * @param {string | null} name
431
+ * @param {Node | Node[] | string | null} value
544
432
  */
545
- function hostOwnsAssignedNode(host, node) {
546
- let p = node.parentElement;
547
- while (p && p !== host) {
548
- if (p.tagName === 'SLOT' && p.hasAttribute(LIGHT_SLOT_ATTR)) {
549
- let q = p.parentElement;
550
- while (q && q !== host) q = q.parentElement;
551
- return q === host;
552
- }
553
- p = p.parentElement;
554
- }
555
- return false;
433
+ export function setSlotContent(host, name, value) {
434
+ const state = ensureSlotState(host);
435
+ const key = keyOfName(name);
436
+ const nodes = normalizeSlotValue(host, value);
437
+ if (nodes.length) state.assignedByName.set(key, nodes);
438
+ else state.assignedByName.delete(key);
439
+ applySlotAssignments(host);
556
440
  }
557
441
 
558
- // ---------------------------------------------------------------------------
559
- // Microtask-batched projection scheduler
560
- // ---------------------------------------------------------------------------
561
-
562
442
  /**
563
- * Schedule a projection pass for the host on the next microtask. Multiple
564
- * calls within one synchronous task collapse to one projection. This is
565
- * the entry point every observer callback uses.
566
- *
567
443
  * @param {Element} host
444
+ * @param {Node | Node[] | string | null} value
445
+ * @returns {Node[]}
568
446
  */
569
- export function scheduleProjection(host) {
570
- const state = ensureSlotState(host);
571
- if (state.scheduled) return;
572
- state.scheduled = true;
573
- queueMicrotask(() => {
574
- state.scheduled = false;
575
- projectChildren(host);
576
- });
447
+ function normalizeSlotValue(host, value) {
448
+ if (value == null) return [];
449
+ if (typeof value === 'string') {
450
+ const doc = host.ownerDocument || (typeof document !== 'undefined' ? document : null);
451
+ return doc ? [doc.createTextNode(value)] : [];
452
+ }
453
+ if (Array.isArray(value)) return value.filter(Boolean);
454
+ return [value];
577
455
  }
578
456
 
579
457
  // ---------------------------------------------------------------------------
580
- // Core projection routine
458
+ // Render-owned slot application
581
459
  // ---------------------------------------------------------------------------
582
460
 
583
461
  /**
584
- * Walk the host's render tree for light-DOM slots, group by their current
585
- * `name` attribute, apply the first-wins assignment rule, then materialise
586
- * the result in DOM. Idempotent and cheap on no-change passes.
462
+ * Place the slot record into the host's OWN light-DOM slots (#1015). The
463
+ * renderer's slot parts call this after the template commits, and
464
+ * `setSlotContent` calls it after a record update. Idempotent and cheap on
465
+ * no-change passes.
587
466
  *
588
- * 1. Each slot is marked data-projection="actual" or "fallback".
589
- * 2. Each "actual" slot has its current children replaced by the
590
- * assigned nodes (Node identity preserved for already-projected
591
- * refs by reusing the same Node objects).
592
- * 3. Each "fallback" slot is reset by the framework's slot-part apply
593
- * step in render-client.js, which restores the compiled fallback
594
- * template.
595
- * 4. slotchange fires on slots whose assigned-node set changed.
467
+ * 1. Collect the host's OWN slots (no other custom element between the
468
+ * slot and the host; a nested component's slots belong to it).
469
+ * 2. Group by `name`, first-wins: the first slot of each name shows the
470
+ * record content, later duplicates show fallback.
471
+ * 3. `data-projection` is stamped "actual" or "fallback" accordingly;
472
+ * fallback content swaps through the part-owned holding fragment.
473
+ * 4. `slotchange` fires on slots whose assigned set actually changed.
596
474
  *
597
475
  * @param {Element} host
598
476
  */
599
- export function projectChildren(host) {
477
+ export function applySlotAssignments(host) {
600
478
  if (!inBrowser) return;
601
479
  const state = /** @type {SlotState | undefined} */ (
602
480
  /** @type {any} */ (host)[SLOT_STATE]
603
481
  );
604
482
  if (!state) return;
605
483
 
606
- // 1. Collect every owned slot in document order.
607
- const slots = /** @type {HTMLSlotElement[]} */ (
608
- Array.from(host.querySelectorAll(`slot[${LIGHT_SLOT_ATTR}]`))
609
- );
610
-
611
- // 2. Reconcile ownedSlots membership. Slots that disappeared from the
612
- // render tree have their assigned children moved to pending so a future
613
- // re-render can re-project them (slot inside a conditional that
614
- // collapsed, etc.).
615
- const newOwned = new Set(slots);
616
- for (const slot of state.ownedSlots) {
617
- if (!newOwned.has(slot)) {
618
- handleSlotRemoved(state, slot);
619
- }
620
- }
621
- for (const slot of newOwned) {
622
- if (!state.ownedSlots.has(slot)) {
623
- attachNameObserver(host, slot);
624
- }
484
+ // 1. The host's own slots, document order.
485
+ /** @type {HTMLSlotElement[]} */
486
+ const slots = [];
487
+ for (const el of host.querySelectorAll(`slot[${LIGHT_SLOT_ATTR}]`)) {
488
+ if (isOwnSlot(host, el)) slots.push(/** @type {HTMLSlotElement} */ (el));
625
489
  }
626
- state.ownedSlots = newOwned;
627
490
 
628
- // 3. Group slots by current `name` attribute in document order.
491
+ // 2. Group by current `name` attribute in document order.
629
492
  /** @type {Map<string|null, HTMLSlotElement[]>} */
630
493
  const groups = new Map();
631
494
  for (const slot of slots) {
632
- const name = slot.getAttribute('name') || null;
495
+ const name = keyOfName(slot.getAttribute('name'));
633
496
  let arr = groups.get(name);
634
497
  if (!arr) {
635
498
  arr = [];
@@ -638,24 +501,7 @@ export function projectChildren(host) {
638
501
  arr.push(slot);
639
502
  }
640
503
 
641
- // 4. Drain pending children into assignedByName per group. Pending
642
- // represents previously-displayed projection (a slot disappeared and
643
- // is reappearing); putting it ahead of newer captured children keeps
644
- // visual order stable across conditional toggles.
645
- for (const name of groups.keys()) {
646
- const pending = state.pendingByName.get(name);
647
- if (pending && pending.length) {
648
- const current = state.assignedByName.get(name) || [];
649
- const merged = [];
650
- for (const n of pending) merged.push(n);
651
- for (const n of current) if (merged.indexOf(n) === -1) merged.push(n);
652
- state.assignedByName.set(name, merged);
653
- state.pendingByName.delete(name);
654
- }
655
- }
656
-
657
- // 5. Assign per the first-wins rule. Primary (index 0) of each group
658
- // gets the actual children; the rest show fallback.
504
+ // 3. Assign per the first-wins rule.
659
505
  /** @type {HTMLSlotElement[]} */
660
506
  const slotsChanged = [];
661
507
  for (const [name, group] of groups) {
@@ -672,30 +518,24 @@ export function projectChildren(host) {
672
518
  }
673
519
  }
674
520
 
675
- // 6. Dispatch slotchange on slots whose assignment actually changed.
521
+ // 4. Dispatch slotchange on slots whose assignment actually changed.
676
522
  for (const slot of slotsChanged) fireSlotChange(slot);
677
523
  }
678
524
 
679
525
  /**
680
- * Move a slot's previous assignment to the pending map keyed by the
681
- * slot's last-known name. Used when the slot itself disappears from the
682
- * render tree (e.g., conditional collapse).
526
+ * True when `slot` belongs to `host` directly: no OTHER custom element
527
+ * sits between them. A slot nested inside a child custom element belongs
528
+ * to THAT component and is applied from its own record.
683
529
  *
684
- * @param {SlotState} state
685
- * @param {HTMLSlotElement} slot
530
+ * @param {Element} host
531
+ * @param {Element} slot
532
+ * @returns {boolean}
686
533
  */
687
- function handleSlotRemoved(state, slot) {
688
- const obs = state.nameObservers.get(slot);
689
- if (obs) {
690
- obs.disconnect();
691
- state.nameObservers.delete(slot);
692
- }
693
- const prev = state.lastSnapshot.get(slot);
694
- if (prev && prev.length > 0) {
695
- const lastName = slot.getAttribute('name') || null;
696
- appendArrayToMap(state.pendingByName, lastName, prev);
534
+ function isOwnSlot(host, slot) {
535
+ for (let p = slot.parentElement; p && p !== host; p = p.parentElement) {
536
+ if (p.tagName.includes('-')) return false;
697
537
  }
698
- state.lastSnapshot.delete(slot);
538
+ return true;
699
539
  }
700
540
 
701
541
  /**
@@ -715,6 +555,17 @@ export function applyActualAssignment(state, slot, assigned) {
715
555
  const equal = !wasFallback && arraysEqual(prev, assigned);
716
556
  if (equal) return false;
717
557
 
558
+ // Fast path: the assigned nodes are ALREADY the slot's children in the
559
+ // same order (the router's morph reconciles in place, then syncs the
560
+ // record). Skip the detach/re-append churn (which would bounce nested
561
+ // custom elements through a disconnect/connect cycle) and just settle
562
+ // the snapshot; slotchange still reflects the set change vs the
563
+ // previous snapshot.
564
+ if (!wasFallback && arraysEqual(Array.from(slot.childNodes), assigned)) {
565
+ state.lastSnapshot.set(slot, assigned.slice());
566
+ return !arraysEqual(prev, assigned);
567
+ }
568
+
718
569
  // Preserve fallback content. If the slot currently holds fallback nodes
719
570
  // (either because we just hydrated from SSR's data-projection="fallback"
720
571
  // or because the slot-part placed them there at bind time), move them
@@ -736,11 +587,10 @@ export function applyActualAssignment(state, slot, assigned) {
736
587
  }
737
588
 
738
589
  /**
739
- * Set a slot to fallback mode. slot.js clears any actual-assignment
740
- * children (moving them to pending for re-projection later) and sets the
741
- * marker attribute. The compiled fallback template is restored by the
742
- * slot-part's apply step in render-client.js, since the fallback content
743
- * may include template holes that the renderer needs to bind.
590
+ * Set a slot to fallback mode: clear any actual-assignment children and
591
+ * restore the part-owned fallback fragment. The record keeps the nodes
592
+ * (they are values now), so a later `setSlotContent` or slot re-creation
593
+ * re-places them.
744
594
  *
745
595
  * @param {SlotState} state
746
596
  * @param {HTMLSlotElement} slot
@@ -757,12 +607,8 @@ export function applyFallback(state, slot) {
757
607
  return false;
758
608
  }
759
609
 
760
- // Slot transitioning from actual to fallback. This happens when the
761
- // host's assignment for this slot's name is empty (e.g., user removed
762
- // all matching children). Drop the lastSnapshot record; do NOT push
763
- // children to the pending map (that path is for slot destruction
764
- // during a conditional collapse, handled separately by
765
- // moveSlotChildrenToPending).
610
+ // Slot transitioning from actual to fallback (the record no longer has
611
+ // content for this name).
766
612
  state.lastSnapshot.delete(slot);
767
613
  while (slot.firstChild) slot.removeChild(slot.firstChild);
768
614
  restoreFallbackInto(slot);
@@ -783,57 +629,31 @@ function restoreFallbackInto(slot) {
783
629
  slot.appendChild(frag);
784
630
  }
785
631
 
786
- /**
787
- * Attach a MutationObserver to a slot's `name` attribute so re-targeting
788
- * (via dynamic name change) triggers re-projection.
789
- *
790
- * @param {Element} host
791
- * @param {HTMLSlotElement} slot
792
- */
793
- function attachNameObserver(host, slot) {
794
- if (!inBrowser) return;
795
- const state = ensureSlotState(host);
796
- if (state.nameObservers.has(slot)) return;
797
- const obs = new MutationObserver(() => scheduleProjection(host));
798
- obs.observe(slot, { attributes: true, attributeFilter: ['name'] });
799
- state.nameObservers.set(slot, obs);
800
- }
801
-
802
632
  // ---------------------------------------------------------------------------
803
- // Slot-part teardown hook (called from render-client.js)
633
+ // Renderer teardown hook (called from render-client.js)
804
634
  // ---------------------------------------------------------------------------
805
635
 
806
636
  /**
807
- * Move a slot's currently-projected children to the host's pending map
808
- * before the slot DOM element is removed by the framework's template
809
- * teardown (e.g., a conditional fragment collapsing). Called by
810
- * render-client.js immediately before disposing the slot-part.
811
- *
812
- * Without this hook the children would be torn down by the renderer's
813
- * generic clearInstance() and lose DOM identity.
637
+ * Detach a slot's record-owned children from the slot element before the
638
+ * renderer's template teardown disposes the slot's subtree (a conditional
639
+ * fragment collapsing). The RECORD keeps the node references, so when a
640
+ * re-render re-creates the slot, `applySlotAssignments` re-places the very
641
+ * same nodes: children are values, teardown never disposes consumer nodes.
814
642
  *
815
643
  * @param {Element} host
816
644
  * @param {HTMLSlotElement} slot
817
645
  */
818
- export function moveSlotChildrenToPending(host, slot) {
646
+ export function rescueAssignedNodes(host, slot) {
819
647
  if (!hasSlotState(host)) return;
820
648
  const state = ensureSlotState(host);
821
- const projected = state.lastSnapshot.get(slot);
822
- if (!projected || projected.length === 0) return;
823
- const name = slot.getAttribute('name') || null;
824
- // Detach nodes from the slot so the renderer's clearInstance doesn't
825
- // dispose them.
826
- for (const node of projected) {
827
- if (node.parentNode === slot) slot.removeChild(node);
649
+ const name = keyOfName(slot.getAttribute('name'));
650
+ const assigned = state.assignedByName.get(name);
651
+ if (assigned) {
652
+ for (const node of assigned) {
653
+ if (node.parentNode === slot) slot.removeChild(node);
654
+ }
828
655
  }
829
- appendArrayToMap(state.pendingByName, name, projected);
830
656
  state.lastSnapshot.delete(slot);
831
- const obs = state.nameObservers.get(slot);
832
- if (obs) {
833
- obs.disconnect();
834
- state.nameObservers.delete(slot);
835
- }
836
- state.ownedSlots.delete(slot);
837
657
  }
838
658
 
839
659
  // ---------------------------------------------------------------------------