@webjsdev/core 0.7.40 → 0.7.41

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,46 +1,80 @@
1
1
  /**
2
- * Light-DOM <slot> runtime for @webjsdev/core: CHILDREN AS VALUES (#1015).
3
- *
4
- * Provides functional parity with shadow-DOM <slot> projection inside
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.
2
+ * Light-DOM <slot> runtime for @webjsdev/core: FULL NATIVE PARITY (#1021).
3
+ *
4
+ * `<slot>` works identically in light DOM and shadow DOM, through the SAME
5
+ * native DOM API. You write the same template, and moving a component between
6
+ * `static shadow = false` and `true` never needs a rewrite. A FORWARDED slot
7
+ * (a template forwarding `<slot>` into a nested component) projects its
8
+ * content on the client and through hydration too (#1023): the renderer
9
+ * stamps each slot with its template owner (SLOT_OWNER), carried across SSR
10
+ * as `data-wj-slot-owner`, so a forwarded slot routes to the OUTER host that
11
+ * rendered it, not the child it nests in. Native `<slot>` is
12
+ * a shadow-DOM primitive, so in light DOM WebJs implements slotting itself, to
13
+ * spec: named + default slots, fallback content, first-wins resolution, dynamic
14
+ * `name=${...}`, and live post-mount writes (appendChild, insertBefore,
15
+ * removeChild, innerHTML, `el.slot=` flips, HTMLSlotElement.assign) plus the
16
+ * full read surface (assignedNodes / assignedElements / {flatten} /
17
+ * assignedSlot / slotchange, with native async-coalesced slotchange timing).
18
+ * One caveat rides assign() specifically. The light-DOM assign() is an
19
+ * EXTENSION of native (an element-bound per-node overlay while name matching
20
+ * keeps working); native shadow assign() requires slotAssignment 'manual' on
21
+ * the shadow root, which WebJs does not set, so in `static shadow = true`
22
+ * mode assign() is a native no-op. Avoid assign() in a component meant to
23
+ * flip modes.
24
+ *
25
+ * ONE WRITER. The design's core invariant: the component's own renderer is the
26
+ * only actor that moves authored nodes into slots (`applySlotAssignments`).
27
+ * `authored: Node[]` is the ordered source of truth; `assignedByName` is a pure
28
+ * derivation (`repartition`). Liveness comes from re-running that one writer,
29
+ * never from a second node-mover. This is what the pre-#1016 architecture got
30
+ * wrong: it live-re-projected via a MutationObserver that PHYSICALLY MOVED
31
+ * nodes, a third DOM writer beside the renderer and the client router, and the
32
+ * ownership overlap was the #906/#908/#912/#914, #1006, and #994 bug cascade.
33
+ *
34
+ * How liveness reaches the one writer:
35
+ * - Interception: the mutating methods are patched per-instance on a light
36
+ * host; an author write updates `authored` + repartitions + applies.
37
+ * - Renderer-write window (RENDERING): the renderer opens it around every
38
+ * host-receiver commit (including the async paths), so a renderer commit is
39
+ * never mistaken for authored content. The one discriminator, structural.
40
+ * - Sensors (read-only, never move nodes): a childList backstop for raw
41
+ * bypass writes, and a slot/name flip sensor for attribute flips.
42
+ * - Prune rule: a node the author detaches (el.remove()) or re-parents is
43
+ * dropped from `authored` by its real parent, killing zombie resurrection
44
+ * and cross-host theft.
45
+ * - Self-heal, in resyncActualSlots. The record is NOT the only legitimate
46
+ * writer INSIDE a slot; a parent component's hole committed there and a
47
+ * library operating on the assigned container are folded back into
48
+ * `authored` before every apply, with NODE-scoped order authority
49
+ * (physical order adopted except for the exact nodes a record op
50
+ * touched), so the one writer never destroys another writer's work.
51
+ *
52
+ * Documented inherent gaps (all from light DOM having no shadow boundary): structural
53
+ * host reads (`host.children` / `childNodes` / the innerHTML GETTER show the
54
+ * rendered template, not the authored children), `assignedChild.parentNode` is
55
+ * the `<slot>`, `::slotted()` CSS (use normal selectors / Tailwind), and
56
+ * initial-projection lifecycle timing: the first light-DOM projection lands
57
+ * one microtask AFTER the first render, so `firstUpdated` sees the `<slot>`
58
+ * element with EMPTY `assignedNodes()` (shadow DOM projects natively before
59
+ * it); read assigned content from `slotchange` or after a microtask.
60
+ *
61
+ * Live writes need the component's JS on the page. Interception + sensors
62
+ * install in connectedCallback, so a component the framework ELIDES (a
63
+ * display-only slotted wrapper with no client signal) ships no JS and its
64
+ * post-mount native writes are inert, like anything on an elided component.
65
+ * A component that is actually interacted with ships (a client module
66
+ * references its tag); for an imperative consumer reaching it through a
67
+ * string selector the analyser cannot see, force the ship with
68
+ * `static interactive = true`. Shadow components always ship (the DSD
69
+ * carve-out), so this is the one place elision, not slots, sets the boundary.
33
70
  *
34
71
  * Polyfill safety. Every prototype patch checks for the `data-webjs-light`
35
- * attribute on the slot element and falls through to the saved native
36
- * implementation otherwise. Real shadow-DOM slots elsewhere on the page
37
- * keep their native behaviour exactly.
72
+ * attribute and falls through to native otherwise, so real shadow-DOM slots
73
+ * keep native behaviour exactly.
38
74
  *
39
- * SSR. This module is import-safe in Node. The polyfill setup is guarded
40
- * on `typeof HTMLSlotElement !== 'undefined'`, so the server pipeline
41
- * loads slot.js without blowing up. Server-side slot substitution lives
42
- * in render-server.js (injectDSD); slot.js drives the client runtime
43
- * only.
75
+ * SSR. This module is import-safe in Node (DOM access is guarded on
76
+ * `typeof HTMLSlotElement !== 'undefined'`). Server-side slot substitution
77
+ * lives in render-server.js (injectDSD); slot.js drives the client runtime.
44
78
  */
45
79
 
46
80
  // ---------------------------------------------------------------------------
@@ -77,6 +111,21 @@ export const PROJECTION_FALLBACK = 'fallback';
77
111
  */
78
112
  export const SLOT_FALLBACK_FRAG = Symbol('webjs.slot.fallbackFrag');
79
113
 
114
+ /**
115
+ * The host whose TEMPLATE produced this `<slot>` (the render container the
116
+ * renderer cloned the template into). Authoritative over the structural
117
+ * `isOwnSlot` walk: a slot a template FORWARDS into a nested component
118
+ * (`html\`<inner><slot></slot></inner>\``) sits physically inside that child
119
+ * but is owned by the OUTER host, which the structural walk (a custom element
120
+ * sits between them) gets wrong. Stamped by the renderer at bind time
121
+ * (render-client) and resolved from the SSR `data-wj-slot-owner` attribute on
122
+ * hydration so client-only mount and hydration share one mechanism.
123
+ */
124
+ export const SLOT_OWNER = Symbol('webjs.slot.templateOwner');
125
+
126
+ /** The SSR carrier for SLOT_OWNER (a symbol cannot cross the HTML boundary). */
127
+ export const SLOT_OWNER_ATTR = 'data-wj-slot-owner';
128
+
80
129
  /** Maximum recursion depth for assignedNodes({flatten: true}); guards cycles. */
81
130
  const FLATTEN_MAX_DEPTH = 64;
82
131
 
@@ -94,6 +143,7 @@ const FLATTEN_MAX_DEPTH = 64;
94
143
  let NATIVE_assignedNodes = null;
95
144
  let NATIVE_assignedElements = null;
96
145
  let NATIVE_assignedSlot_desc = null;
146
+ let NATIVE_assign = null;
97
147
  let polyfillsInstalled = false;
98
148
 
99
149
  /**
@@ -144,9 +194,135 @@ export function installSlotPolyfills() {
144
194
  return findLightAssignedSlot(this);
145
195
  },
146
196
  });
197
+
198
+ // Native `assignedSlot` lives on the Slottable mixin, which covers Text as
199
+ // well as Element: a projected text child reports its slot in shadow DOM,
200
+ // so the light parity read must answer for Text too.
201
+ const NATIVE_text_assignedSlot_desc = Object.getOwnPropertyDescriptor(
202
+ Text.prototype,
203
+ 'assignedSlot',
204
+ );
205
+ Object.defineProperty(Text.prototype, 'assignedSlot', {
206
+ configurable: true,
207
+ enumerable: true,
208
+ get: function patchedTextAssignedSlot() {
209
+ const native =
210
+ NATIVE_text_assignedSlot_desc && NATIVE_text_assignedSlot_desc.get
211
+ ? NATIVE_text_assignedSlot_desc.get.call(this)
212
+ : null;
213
+ if (native) return native;
214
+ return findLightAssignedSlot(this);
215
+ },
216
+ });
217
+
218
+ NATIVE_assign = HTMLSlotElement.prototype.assign;
219
+ HTMLSlotElement.prototype.assign = function patchedAssign(...nodes) {
220
+ if (this.hasAttribute(LIGHT_SLOT_ATTR) && !isInShadowRoot(this)) {
221
+ // Manual slot assignment (imperative, overrides attribute mode). Bound
222
+ // to THIS slot element (native binds slottables to the receiving
223
+ // element, not its name), held via WeakRef (native holds manually
224
+ // assigned slottables weakly), honored by repartition through
225
+ // effectiveKeyOf and by the placement step's per-element routing.
226
+ // NOTE this is a deliberate EXTENSION of native: real manual mode
227
+ // requires slotAssignment 'manual' on the whole shadow root and turns
228
+ // name matching off; here assign() overlays per-node while name
229
+ // matching keeps working for everything else.
230
+ const host = hostOfSlot(this);
231
+ if (host) {
232
+ const state = ensureSlotState(host);
233
+ if (!state.manualAssign) state.manualAssign = new Map();
234
+ const list = nodes.filter(Boolean);
235
+ // LAST-assign-wins: a node handed to this slot leaves any other
236
+ // slot's manual list.
237
+ for (const [slotEl, refs] of state.manualAssign) {
238
+ if (slotEl === this) continue;
239
+ const kept = refs.filter((r) => {
240
+ const n = r.deref();
241
+ return n !== undefined && list.indexOf(n) === -1;
242
+ });
243
+ if (kept.length !== refs.length) {
244
+ if (kept.length) state.manualAssign.set(slotEl, kept);
245
+ else state.manualAssign.delete(slotEl);
246
+ }
247
+ }
248
+ if (list.length) {
249
+ state.manualAssign.set(this, list.map((n) => new WeakRef(n)));
250
+ } else {
251
+ state.manualAssign.delete(this);
252
+ }
253
+ if (state.applying && state.pendingRecordNodes) {
254
+ for (const n of list) state.pendingRecordNodes.add(n);
255
+ } else {
256
+ state.pendingRecordNodes = new Set(list);
257
+ }
258
+ repartition(state);
259
+ applySlotAssignments(host);
260
+ }
261
+ return undefined;
262
+ }
263
+ return NATIVE_assign ? NATIVE_assign.apply(this, nodes) : undefined;
264
+ };
147
265
  polyfillsInstalled = true;
148
266
  }
149
267
 
268
+ /**
269
+ * The slot ELEMENT a node is manually assigned to via `assign()`, or null.
270
+ * Dead WeakRefs are compacted on the way through.
271
+ *
272
+ * @param {SlotState} state
273
+ * @param {Node} node
274
+ * @returns {HTMLSlotElement | null}
275
+ */
276
+ function manualSlotFor(state, node) {
277
+ const manual = state.manualAssign;
278
+ if (!manual || !manual.size) return null;
279
+ for (const [slotEl, refs] of manual) {
280
+ let found = false;
281
+ const live = refs.filter((r) => {
282
+ const n = r.deref();
283
+ if (n === undefined) return false;
284
+ if (n === node) found = true;
285
+ return true;
286
+ });
287
+ if (live.length !== refs.length) {
288
+ if (live.length) manual.set(slotEl, live);
289
+ else manual.delete(slotEl);
290
+ }
291
+ if (found) {
292
+ // A manual entry is honoured only while its RECEIVING element is still
293
+ // part of this host's tree. A torn-down (conditionally re-rendered)
294
+ // slot element leaves the entry DORMANT: the node falls back to its
295
+ // slot= attribute (routed by name or parked, native's
296
+ // unassigned-but-connected behaviour) instead of being excluded from
297
+ // every slot and lost. The entry is kept, not deleted, so a re-attached
298
+ // element (the cache directive) resumes its assignment, matching
299
+ // native's element-bound persistence.
300
+ const hostEl = state.host;
301
+ if (hostEl && (slotEl === hostEl || hostEl.contains(slotEl))) return slotEl;
302
+ return null;
303
+ }
304
+ }
305
+ return null;
306
+ }
307
+
308
+ /**
309
+ * Walk up from a light slot to its owning host: the nearest SLOT_STATE
310
+ * ancestor that actually OWNS the slot. A slot separated from that ancestor
311
+ * by another custom element (a forwarded slot inside a foreign / elided
312
+ * component) is nobody's here: attributing it to the outer host would
313
+ * redirect the outer host's own same-named slot, so `assign()` on such a
314
+ * slot is inert instead (the same carve-out as every other elided-component
315
+ * write).
316
+ */
317
+ function hostOfSlot(slot) {
318
+ for (let p = slot.parentElement; p; p = p.parentElement) {
319
+ if (/** @type {any} */ (p)[SLOT_STATE]) {
320
+ return isOwnSlot(p, slot) ? p : null;
321
+ }
322
+ }
323
+ return null;
324
+ }
325
+
150
326
  // First-chance install at module load.
151
327
  installSlotPolyfills();
152
328
 
@@ -185,7 +361,12 @@ function isInShadowRoot(node) {
185
361
  * @returns {Node[]}
186
362
  */
187
363
  function lightAssignedNodes(slot, options) {
188
- if (slot.getAttribute(PROJECTION_ATTR) === PROJECTION_FALLBACK) return [];
364
+ // Only an APPLIED actual slot has assigned nodes. A fallback slot reports
365
+ // [], and so does a slot with NO data-projection at all (an orphan slot
366
+ // rendered outside any host, or one not yet placed): its children are
367
+ // cloned fallback content, and native returns [] for a slot outside a
368
+ // shadow tree.
369
+ if (slot.getAttribute(PROJECTION_ATTR) !== PROJECTION_ACTUAL) return [];
189
370
  const direct = Array.from(slot.childNodes);
190
371
  if (!options || !options.flatten) return direct;
191
372
  return flattenAssignedNodes(direct, new Set(), 0);
@@ -233,7 +414,7 @@ function flattenAssignedNodes(nodes, visited, depth) {
233
414
  }
234
415
 
235
416
  /**
236
- * Walk an element's ancestor chain to find a data-webjs-light slot it is
417
+ * Consult a node's DIRECT parent to find a data-webjs-light slot it is
237
418
  * currently projected into. Returns null if the element is in a fallback
238
419
  * slot or no light slot at all.
239
420
  *
@@ -241,14 +422,16 @@ function flattenAssignedNodes(nodes, visited, depth) {
241
422
  * @returns {HTMLSlotElement | null}
242
423
  */
243
424
  function findLightAssignedSlot(el) {
244
- let p = el.parentElement;
245
- while (p) {
246
- if (p.tagName === 'SLOT' && p.hasAttribute(LIGHT_SLOT_ATTR)) {
247
- return p.getAttribute(PROJECTION_ATTR) === PROJECTION_ACTUAL
248
- ? /** @type {HTMLSlotElement} */ (p)
249
- : null;
250
- }
251
- p = p.parentElement;
425
+ // Native `assignedSlot` answers only for a SLOTTABLE itself. In the light
426
+ // parity model, assigned nodes are exactly the slot element's DIRECT
427
+ // children, so only the immediate parent is consulted: a DESCENDANT of
428
+ // assigned content correctly reads null, matching shadow DOM (where only
429
+ // the host's direct children are slottables).
430
+ const p = el.parentElement;
431
+ if (p && p.tagName === 'SLOT' && p.hasAttribute(LIGHT_SLOT_ATTR)) {
432
+ return p.getAttribute(PROJECTION_ATTR) === PROJECTION_ACTUAL
433
+ ? /** @type {HTMLSlotElement} */ (p)
434
+ : null;
252
435
  }
253
436
  return null;
254
437
  }
@@ -259,11 +442,48 @@ function findLightAssignedSlot(el) {
259
442
 
260
443
  /**
261
444
  * @typedef {Object} SlotState
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.
445
+ * @property {Element} host The owning host element (back-reference so
446
+ * state-only helpers can test containment).
447
+ * @property {Node[]} authored The ordered source of truth: every authored
448
+ * child of the host, in host-child order. `assignedByName` is DERIVED from
449
+ * this by `repartition` (grouping each node by its current `slot=`
450
+ * attribute), so there is one place a node's assignment is decided.
451
+ * @property {Map<string|null, Node[]>} assignedByName The DERIVED slot record:
452
+ * `authored` grouped per slot name (null is the default slot). Never mutated
453
+ * directly; always rebuilt by `repartition`.
265
454
  * @property {WeakMap<HTMLSlotElement, Node[]>} lastSnapshot Per-slot
266
455
  * record of the previous assigned-node set for slotchange equality.
456
+ * @property {Set<HTMLSlotElement>} [pendingSlotChanges] Slots whose
457
+ * assignment changed since the last microtask flush (coalesced slotchange).
458
+ * @property {boolean} [slotChangeScheduled] True while a coalesced
459
+ * slotchange flush is queued for this host.
460
+ * @property {MutationObserver} [backstop] Sensor for raw direct-child writes
461
+ * that bypass the patched methods (never moves nodes; folds into `authored`).
462
+ * @property {MutationObserver} [flipSensor] Sensor for `slot=` / `name=`
463
+ * attribute flips (never moves nodes; re-derives + re-places).
464
+ * @property {Set<Node> | undefined} [pendingRecordNodes] The nodes the
465
+ * current record op touched (inserted, moved, or removed by an interceptor
466
+ * / assign() / router splice), consumed by the next apply pass: the resync
467
+ * step honours record positions for exactly these nodes and adopts
468
+ * physical order for everything else (node-scoped order authority).
469
+ * @property {boolean} [applying] Re-entrancy latch: true while an apply
470
+ * pass runs; a nested call flags `reapply` and returns.
471
+ * @property {boolean} [reapply] Set by a nested apply attempt; the outer
472
+ * pass loops until a full pass completes without it.
473
+ * @property {boolean} [adopted] True when this state was populated by the
474
+ * ADOPT connect branch (SSR hydration / serialized restore): the host's
475
+ * pre-first-render children are rendered markup, so the reconnect fold
476
+ * must not hoover them.
477
+ * @property {WeakMap<Node, string|null>} [adoptedKey] The slot key a
478
+ * self-heal fold ADOPTED for a node a non-record writer placed inside a
479
+ * named slot (its own attribute would key it elsewhere); cleared when the
480
+ * author explicitly changes the node's slot= attribute.
481
+ * @property {Map<HTMLSlotElement, WeakRef<Node>[]>} [manualAssign] Overlay for
482
+ * `HTMLSlotElement.assign()` manual assignment, keyed by the RECEIVING slot
483
+ * element (native binds slottables to the element, so a rename follows the
484
+ * element and duplicates route correctly); nodes held via WeakRef (native
485
+ * holds manually assigned slottables weakly). A node here goes to its
486
+ * assigned slot regardless of its `slot=` attribute.
267
487
  */
268
488
 
269
489
  /**
@@ -278,6 +498,8 @@ export function ensureSlotState(host) {
278
498
  let state = h[SLOT_STATE];
279
499
  if (!state) {
280
500
  state = {
501
+ host,
502
+ authored: [],
281
503
  assignedByName: new Map(),
282
504
  lastSnapshot: new WeakMap(),
283
505
  };
@@ -309,10 +531,56 @@ export function captureAuthoredChildren(host) {
309
531
  const state = ensureSlotState(host);
310
532
  while (host.firstChild) {
311
533
  const node = host.firstChild;
312
- const name = slotNameOf(node);
313
- appendToMap(state.assignedByName, name, node);
534
+ state.authored.push(node);
535
+ // Detached by the framework, awaiting placement at slot-apply time: the
536
+ // prune rule must not treat this parentless window as an author removal.
537
+ FRAMEWORK_DETACHED.add(node);
314
538
  host.removeChild(node);
315
539
  }
540
+ repartition(state);
541
+ }
542
+
543
+ /**
544
+ * Rebuild `assignedByName` from `authored`: group every authored node by its
545
+ * current `slot=""` attribute (default = null key). Pure and idempotent, the
546
+ * single place a node's slot assignment is decided. Called after any change
547
+ * to `authored` and at the top of `applySlotAssignments`.
548
+ *
549
+ * @param {SlotState} state
550
+ */
551
+ export function repartition(state) {
552
+ const byName = state.assignedByName;
553
+ byName.clear();
554
+ for (const node of state.authored) {
555
+ appendToMap(byName, effectiveKeyOf(state, node), node);
556
+ }
557
+ }
558
+
559
+ /**
560
+ * The slot key a node is ASSIGNED under: the manual `HTMLSlotElement.assign()`
561
+ * overlay when the node is named in one, else the node's `slot=""` attribute.
562
+ * The ONE key rule, shared by `repartition`, the park step, and the router
563
+ * seam, so a manually-assigned node is never judged by its overlay in one
564
+ * place and its raw attribute in another (which parked an assigned node out
565
+ * of its own slot).
566
+ *
567
+ * @param {SlotState} state
568
+ * @param {Node} node
569
+ * @returns {string | null}
570
+ */
571
+ function effectiveKeyOf(state, node) {
572
+ const m = manualSlotFor(state, node);
573
+ if (m) return keyOfName(m.getAttribute('name'));
574
+ // A node a NON-record writer placed inside a NAMED slot (folded by the
575
+ // self-heal resync) keeps the key of the container it was written into;
576
+ // deriving from its (absent or different) slot= attribute would teleport
577
+ // it to the default slot on the next apply. An explicit later slot=
578
+ // change clears the adoption (the flip sensor owns that).
579
+ if (state.adoptedKey) {
580
+ const adopted = state.adoptedKey.get(node);
581
+ if (adopted !== undefined) return adopted;
582
+ }
583
+ return slotNameOf(node);
316
584
  }
317
585
 
318
586
  /**
@@ -325,18 +593,87 @@ export function captureAuthoredChildren(host) {
325
593
  */
326
594
  export function adoptSSRAssignments(host) {
327
595
  const state = ensureSlotState(host);
596
+ // Record the connect branch: reconnectSweep's pre-render fold gate keys on
597
+ // this flag (an adopted host's children ARE rendered markup; a captured
598
+ // host's are only bypass writes).
599
+ state.adopted = true;
600
+ /** @type {Set<string|null>} first-wins per name across the host's own slots */
601
+ const seen = new Set();
328
602
  const slots = host.querySelectorAll(`slot[${LIGHT_SLOT_ATTR}]`);
329
603
  for (const slot of slots) {
330
604
  /** @type {HTMLSlotElement} */
331
605
  const s = /** @type {any} */ (slot);
606
+ // Only the host's OWN slots. A nested component's slot (another custom
607
+ // element sits between it and the host) belongs to THAT component and is
608
+ // adopted from its own record. Without this filter, a nested actual slot
609
+ // that precedes the outer host's same-named slot in document order wins
610
+ // the first-wins `has(name)` check below, so the outer record adopts the
611
+ // inner component's children and the outer's first apply physically steals
612
+ // them. `applySlotAssignments` and the router both filter; this path must
613
+ // too.
614
+ if (!isOwnSlot(host, s)) continue;
332
615
  if (s.getAttribute(PROJECTION_ATTR) !== PROJECTION_ACTUAL) continue;
616
+ // The authored-content invariant applies at adopt time too: a slot
617
+ // sitting inside ALREADY-ADOPTED children (an SSR'd forwarded slot
618
+ // serialized as content of an earlier own slot) is content, not an own
619
+ // slot; adopting it would first-wins-collide with a later legitimate
620
+ // same-named own slot and destroy that slot's children. Document order
621
+ // guarantees a container slot precedes its descendants, so the check
622
+ // sees the container's children in `authored` by the time a nested slot
623
+ // is tested.
624
+ if (isInsideAuthored(state, host, s)) continue;
333
625
  const name = keyOfName(s.getAttribute('name'));
334
- if (!state.assignedByName.has(name)) {
626
+ if (!seen.has(name)) {
627
+ seen.add(name);
335
628
  const children = Array.from(s.childNodes);
336
- state.assignedByName.set(name, children);
629
+ // The SSR'd projected children retain their own `slot=` attribute, so
630
+ // pushing them into `authored` and re-deriving reproduces the same
631
+ // per-name grouping without moving any DOM (no flash). Note: `authored`
632
+ // is rebuilt in slot-document order, not the original interleaved
633
+ // host-child order, which SSR did not preserve. Per-name grouping (all
634
+ // that placement uses) is exact; the only observable difference from a
635
+ // fresh mount is the cross-name ordering a post-hydration `insertBefore`
636
+ // with a ref in a DIFFERENT named slot would resolve against.
637
+ // A child whose OWN attribute keys elsewhere (a snapshot-restored
638
+ // adoption or manual assignment, provenance the HTML cannot carry) is
639
+ // re-adopted under the container's key, so the first client render
640
+ // never relocates a node out of the slot the restored markup showed it
641
+ // in.
642
+ for (const child of children) {
643
+ state.authored.push(child);
644
+ // No prune-exemption mark here: when createInstance detaches the old
645
+ // SSR subtree, these children still sit in the OLD slot, which this
646
+ // adopt just recorded in lastSnapshot, so the prune gate keeps them
647
+ // through the detach window. Skipping the mark also means an author
648
+ // removing an adopted child BEFORE the first apply (child.remove()
649
+ // in another component's boot hook) is honoured instead of the node
650
+ // being resurrected by the resync.
651
+ if (slotNameOf(child) !== name) {
652
+ if (!state.adoptedKey) state.adoptedKey = new WeakMap();
653
+ state.adoptedKey.set(child, name);
654
+ }
655
+ }
337
656
  state.lastSnapshot.set(s, children.slice());
338
657
  }
339
658
  }
659
+ // A serialized snapshot (back/forward restore) also carries the PARK: an
660
+ // authored child whose slot name matched no rendered slot at snapshot time
661
+ // sits inside <wj-slot-park>. Sweep its children into the record and drop
662
+ // the serialized park element itself (a fresh park is created on demand),
663
+ // so a parked node survives the restore and a later render that DOES emit
664
+ // its slot pulls it back out.
665
+ for (const oldPark of host.querySelectorAll('wj-slot-park')) {
666
+ if (!isOwnSlot(host, oldPark)) continue;
667
+ for (const child of Array.from(oldPark.childNodes)) {
668
+ // The children keep the DETACHED oldPark as parent until placement;
669
+ // the FRAMEWORK_DETACHED mark is what shields them from the prune
670
+ // rule across that window.
671
+ FRAMEWORK_DETACHED.add(child);
672
+ state.authored.push(child);
673
+ }
674
+ oldPark.remove();
675
+ }
676
+ repartition(state);
340
677
  }
341
678
 
342
679
  /**
@@ -350,7 +687,7 @@ export function adoptSSRAssignments(host) {
350
687
  * @param {string | null | undefined} name
351
688
  * @returns {string | null}
352
689
  */
353
- function keyOfName(name) {
690
+ export function keyOfName(name) {
354
691
  return name == null || name === '' || name === 'default' ? null : name;
355
692
  }
356
693
 
@@ -378,80 +715,915 @@ function appendToMap(map, key, value) {
378
715
  }
379
716
 
380
717
  // ---------------------------------------------------------------------------
381
- // The public children-as-values API (#1015)
718
+ // Router coordination seam
382
719
  // ---------------------------------------------------------------------------
383
720
 
384
721
  /**
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 ? ... : ...`).
722
+ * Replace the authored content assigned to slot `name` with `nodes`, in place
723
+ * of the old slice's position (a new name appends). The ONE public seam the
724
+ * client router uses to reconcile a reused light host's projected content
725
+ * during a same-route morph (replacing the deleted `setSlotContent`): the
726
+ * router never touches `authored` / `assignedByName` / `lastSnapshot` directly,
727
+ * and this is the same record-then-place primitive the interception layer runs.
388
728
  *
389
729
  * @param {Element} host
390
- * @returns {Record<string, Node[]>}
730
+ * @param {string | null} name
731
+ * @param {Node[] | Node | null} nodes
391
732
  */
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();
733
+ export function projectAuthored(host, name, nodes) {
734
+ const state = ensureSlotState(host);
735
+ const key = keyOfName(name);
736
+ const list = Array.isArray(nodes) ? nodes.filter(Boolean) : nodes ? [nodes] : [];
737
+ // Evict the old slice by the node's EFFECTIVE key (the manual assign()
738
+ // overlay when present, else the slot= attribute): filtering on the raw
739
+ // attribute would evict a manually-assigned attribute-less node from the
740
+ // default slice and silently drop another slot's content.
741
+ let at = state.authored.findIndex((n) => effectiveKeyOf(state, n) === key);
742
+ const evicted = state.authored.filter((n) => effectiveKeyOf(state, n) === key);
743
+ state.authored = state.authored.filter((n) => effectiveKeyOf(state, n) !== key);
744
+ if (at === -1 || at > state.authored.length) at = state.authored.length;
745
+ for (const n of list) {
746
+ // Stamp the slice key onto element nodes so the derived partition
747
+ // matches the projection, EXCEPT nodes with a live manual assign()
748
+ // entry: their routing is element-bound (the overlay outranks the
749
+ // attribute), and the attribute is the author's latent intent that must
750
+ // survive the overlay's release.
751
+ if (n.nodeType === 1 && !manualSlotFor(state, n)) {
752
+ if (key == null) /** @type {Element} */ (n).removeAttribute('slot');
753
+ else /** @type {Element} */ (n).setAttribute('slot', key);
754
+ }
755
+ FRAMEWORK_DETACHED.add(n); // prune-exempt until placed
756
+ }
757
+ state.authored.splice(at, 0, ...list);
758
+ if (state.applying && state.pendingRecordNodes) {
759
+ for (const n of evicted) state.pendingRecordNodes.add(n);
760
+ for (const n of list) state.pendingRecordNodes.add(n);
761
+ } else {
762
+ state.pendingRecordNodes = new Set([...evicted, ...list]);
763
+ }
764
+ // Scope the adoption-clear to THIS call's nodes: under the latch the
765
+ // pending set may carry a co-pending nested op's nodes, whose adoptions
766
+ // are not this projection's to end.
767
+ if (state.adoptedKey) {
768
+ for (const n of evicted) state.adoptedKey.delete(n);
769
+ for (const n of list) state.adoptedKey.delete(n);
399
770
  }
400
- return view;
771
+ repartition(state);
772
+ applySlotAssignments(host);
401
773
  }
402
774
 
775
+ // ---------------------------------------------------------------------------
776
+ // Native-write window + host interception (native slot-API liveness)
777
+ // ---------------------------------------------------------------------------
778
+
403
779
  /**
404
- * Does the host's slot record carry content for `name`?
780
+ * Set on a host WHILE the renderer is committing into it. The patched host
781
+ * methods check it and delegate to the saved native, so a renderer commit is
782
+ * never mistaken for authored content. This is the one discriminator between
783
+ * renderer writes and author writes: a synchronous framework-write window, set
784
+ * structurally (an own symbol), never inferred from comment markers.
785
+ */
786
+ export const RENDERING = Symbol('webjs.slot.rendering');
787
+
788
+ /** Marks a host whose mutating methods have been patched (install once). */
789
+ const INTERCEPTED = Symbol('webjs.slot.intercepted');
790
+
791
+ /** The per-host hidden holding element for authored nodes whose slot name
792
+ * matches no rendered slot (native keeps them connected but unrendered). */
793
+ const PARK = Symbol('webjs.slot.park');
794
+
795
+ /**
796
+ * Authored nodes the framework detached ON PURPOSE (capture, teardown rescue),
797
+ * so the prune rule does not treat them as author-removed while they sit
798
+ * parentless waiting to be (re)placed. Cleared once a node is placed.
799
+ */
800
+ const FRAMEWORK_DETACHED = new WeakSet();
801
+
802
+ // Saved native references, captured once in the browser (Node has no `Node`).
803
+ let N_appendChild = null;
804
+ let N_insertBefore = null;
805
+ let N_removeChild = null;
806
+ let N_replaceChild = null;
807
+ let N_append = null;
808
+ let N_prepend = null;
809
+ let N_replaceChildren = null;
810
+ let INNER_HTML_DESC = null;
811
+ let TEXT_CONTENT_DESC = null;
812
+
813
+ function captureNatives() {
814
+ if (N_appendChild || !inBrowser) return;
815
+ N_appendChild = Node.prototype.appendChild;
816
+ N_insertBefore = Node.prototype.insertBefore;
817
+ N_removeChild = Node.prototype.removeChild;
818
+ N_replaceChild = Node.prototype.replaceChild;
819
+ N_append = Element.prototype.append;
820
+ N_prepend = Element.prototype.prepend;
821
+ N_replaceChildren = Element.prototype.replaceChildren;
822
+ INNER_HTML_DESC = Object.getOwnPropertyDescriptor(Element.prototype, 'innerHTML');
823
+ TEXT_CONTENT_DESC = Object.getOwnPropertyDescriptor(Node.prototype, 'textContent');
824
+ }
825
+
826
+ /**
827
+ * Run `fn` with the renderer-write window open on `host`, restoring the prior
828
+ * flag afterward (re-entrancy safe: nested renderer commits nest cleanly). The
829
+ * renderer wraps every host-receiver commit in this; a write to a light host
830
+ * inside the window bypasses the interception and hits the native DOM.
831
+ *
832
+ * @template T
833
+ * @param {any} host
834
+ * @param {() => T} fn
835
+ * @returns {T}
836
+ */
837
+ export function withRendererWrites(host, fn) {
838
+ const prev = host[RENDERING];
839
+ host[RENDERING] = true;
840
+ try {
841
+ return fn();
842
+ } finally {
843
+ host[RENDERING] = prev;
844
+ // When the OUTERMOST window closes, drain the backstop synchronously before
845
+ // its async callback fires. The drain PROCESSES the records (renderer output
846
+ // is skipped structurally, a genuine bypass write is folded), so this
847
+ // commit's own childList churn is absorbed without losing a real author
848
+ // write that coincided in the same task. (The flip sensor is NOT drained: a
849
+ // renderer `name=` write on a slot is exactly what re-projects a dynamic
850
+ // `name=${...}`.)
851
+ if (!prev) drainRendererBackstop(host);
852
+ }
853
+ }
854
+
855
+ /**
856
+ * Install the two read-only sensors on a light host. Neither moves a node; each
857
+ * only folds a mutation into `authored` and calls the single renderer-owned
858
+ * writer. Installed on connect, torn down on disconnect.
859
+ * - Bypass backstop (childList, subtree:false): catches raw writes that skip
860
+ * the patched methods (`Node.prototype.appendChild.call`, Range ops).
861
+ * - Flip sensor (attributes slot/name, subtree:true): catches an `el.slot=`
862
+ * flip on a projected child and a slot `name=` change.
863
+ *
864
+ * @param {Element} host
865
+ */
866
+ export function installSlotSensors(host) {
867
+ if (!inBrowser) return;
868
+ const h = /** @type {any} */ (host);
869
+ const state = ensureSlotState(host);
870
+ if (state.backstop) return;
871
+
872
+ state.backstop = new MutationObserver((records) => processBackstop(host, state, records));
873
+ state.backstop.observe(host, { childList: true, subtree: false });
874
+
875
+ state.flipSensor = new MutationObserver((records) => processFlip(host, records));
876
+ state.flipSensor.observe(host, {
877
+ attributes: true,
878
+ attributeFilter: ['slot', 'name'],
879
+ subtree: true,
880
+ });
881
+ }
882
+
883
+ /**
884
+ * Backstop callback body: fold a raw direct-child add / un-author a raw remove.
885
+ * A renderer-committed node (the render root and everything between the instance
886
+ * bookend markers) is SKIPPED structurally, so this is safe to run on the
887
+ * records drained at a renderer-write window close, not only on genuine
888
+ * author-bypass records. That is what keeps a real bypass write that happened
889
+ * to coincide with a commit in the same task from being silently dropped.
890
+ */
891
+ function processBackstop(host, state, records) {
892
+ const h = /** @type {any} */ (host);
893
+ const park = h[PARK];
894
+ const inst = h[Symbol.for('webjs.instance')];
895
+ let dirty = false;
896
+ // Nodes the AUTHOR touched in THIS pass (any addedNodes appearance): for
897
+ // them, the latest same-pass author action wins, so a following removal
898
+ // record splices even a marked node.
899
+ const touchedThisPass = new Set();
900
+ for (const r of records) {
901
+ for (const node of r.addedNodes) {
902
+ touchedThisPass.add(node);
903
+ if (node === park) continue;
904
+ if (inst && instanceOwns(inst, node)) continue; // renderer output, not authored
905
+ if (state.authored.indexOf(node) !== -1) {
906
+ // A raw bypass MOVE of an already-authored node back onto the host:
907
+ // the record is right but the node now physically sits outside its
908
+ // slot. Re-apply so the physically-verifying placement step repairs
909
+ // it (no slotchange fires: the assigned SET is unchanged).
910
+ dirty = true;
911
+ continue;
912
+ }
913
+ FRAMEWORK_DETACHED.add(node);
914
+ state.authored.push(node);
915
+ dirty = true;
916
+ }
917
+ for (const node of r.removedNodes) {
918
+ const i = state.authored.indexOf(node);
919
+ if (i === -1) continue;
920
+ if (host.contains(node)) continue; // placement/park move: still ours
921
+ // Containment is evaluated at PROCESSING time, so a stale placement
922
+ // record (host to slot move) can be processed after a rescue detached
923
+ // the node as a record value: retain exactly that shape (marked,
924
+ // parentless, untouched by the author this pass). Everything else in
925
+ // a host-childList removal record was author-removed or author-moved
926
+ // (their same-pass add marks touchedThisPass; a placed node is
927
+ // unmarked; a re-homed node has a parent) and leaves the record.
928
+ const rescueValue =
929
+ FRAMEWORK_DETACHED.has(node) &&
930
+ node.parentNode == null &&
931
+ !touchedThisPass.has(node);
932
+ if (!rescueValue) {
933
+ state.authored.splice(i, 1);
934
+ dirty = true;
935
+ }
936
+ }
937
+ }
938
+ if (dirty) applySlotAssignments(host);
939
+ }
940
+
941
+ /**
942
+ * Drain the host's backstop at a renderer-write window close, PROCESSING the
943
+ * records (renderer output is skipped structurally by processBackstop) so a
944
+ * genuine bypass write coinciding with the commit is not lost. Exported so
945
+ * render-client's render() window can share the exact same drain.
405
946
  *
406
947
  * @param {Element} host
407
- * @param {string | null} [name]
948
+ */
949
+ export function drainRendererBackstop(host) {
950
+ const h = /** @type {any} */ (host);
951
+ const state = /** @type {SlotState | undefined} */ (h[SLOT_STATE]);
952
+ if (!(state && state.backstop)) return;
953
+ const records = state.backstop.takeRecords();
954
+ // Only PROCESS when there is a rendered instance: processBackstop skips
955
+ // renderer output via instanceOwns, which needs the instance bookends. On the
956
+ // non-template render path (render() returns a string / array / number) the
957
+ // instance is null and the renderer's own text nodes are direct host
958
+ // children, so processing would fold them into the record and park them (the
959
+ // component would render blank). With no instance to discriminate, discard,
960
+ // matching the pre-processing behavior.
961
+ // Process when an instance exists OR the host has NEVER rendered (the
962
+ // symbol is absent: a pre-first-render bypass write must be folded so the
963
+ // first render's replaceChildren does not silently destroy it); discard
964
+ // only on the EXPLICIT null of the non-template render path, whose text
965
+ // output would otherwise be folded and parked.
966
+ const hasInstanceSym = Symbol.for('webjs.instance') in h;
967
+ if (!hasInstanceSym || h[Symbol.for('webjs.instance')]) {
968
+ processBackstop(host, state, records);
969
+ }
970
+ }
971
+
972
+ /**
973
+ * Flip-sensor callback body: a RELEVANT slot=/name= flip re-derives + re-places.
974
+ * Relevant = a `name=` change on one of the host's own light slots, or a `slot=`
975
+ * change on an authored (projected) child. An unrelated `name=` deep in the tree
976
+ * (e.g. an `<input name>`) is ignored, so common markup does not trigger a
977
+ * spurious full re-apply.
978
+ */
979
+ function processFlip(host, records) {
980
+ const state = /** @type {SlotState | undefined} */ (
981
+ /** @type {any} */ (host)[SLOT_STATE]
982
+ );
983
+ if (!state) return;
984
+ // ONE pass over ALL records first: every explicit slot= change clears that
985
+ // node's self-heal adoption (the author's attribute is now the routing
986
+ // intent), unconditionally. Deleting for a node no longer authored is
987
+ // always safe, and gating on authored membership let a stale adoption
988
+ // outlive a same-task detach and mis-route a later re-append. Only then is
989
+ // relevance decided and the single apply run, so a batch with several
990
+ // relevant flips never leaves a later record's adoption uncleared.
991
+ let relevant = false;
992
+ for (const r of records) {
993
+ if (r.type !== 'attributes') continue;
994
+ const target = /** @type {Element} */ (r.target);
995
+ if (r.attributeName === 'name') {
996
+ if (
997
+ target.tagName === 'SLOT' &&
998
+ target.hasAttribute(LIGHT_SLOT_ATTR) &&
999
+ isOwnSlot(host, target)
1000
+ ) {
1001
+ relevant = true;
1002
+ }
1003
+ } else if (r.attributeName === 'slot') {
1004
+ if (state.adoptedKey) state.adoptedKey.delete(target);
1005
+ if (state.authored.indexOf(target) !== -1) relevant = true;
1006
+ }
1007
+ }
1008
+ if (relevant) applySlotAssignments(host);
1009
+ }
1010
+
1011
+ /**
1012
+ * Tear down the sensors, PROCESSING any queued records first (a bare
1013
+ * `disconnect()` drops them, which would lose a flip or bypass write captured
1014
+ * but not yet delivered when the host disconnects).
1015
+ *
1016
+ * @param {Element} host
1017
+ */
1018
+ export function teardownSlotSensors(host) {
1019
+ const state = /** @type {SlotState | undefined} */ (
1020
+ /** @type {any} */ (host)[SLOT_STATE]
1021
+ );
1022
+ if (!state) return;
1023
+ if (state.backstop) {
1024
+ processBackstop(host, state, state.backstop.takeRecords());
1025
+ state.backstop.disconnect();
1026
+ state.backstop = undefined;
1027
+ }
1028
+ if (state.flipSensor) {
1029
+ processFlip(host, state.flipSensor.takeRecords());
1030
+ state.flipSensor.disconnect();
1031
+ state.flipSensor = undefined;
1032
+ }
1033
+ }
1034
+
1035
+ /**
1036
+ * Reconnect sweep: after a host is re-inserted, fold any direct host child that
1037
+ * is not already authored, not the park, and not the render root into
1038
+ * `authored` (covers a raw bypass write made while the host was disconnected,
1039
+ * which no sensor was live to see). Then re-apply.
1040
+ *
1041
+ * @param {Element} host
1042
+ */
1043
+ export function reconnectSweep(host) {
1044
+ if (!inBrowser) return;
1045
+ const h = /** @type {any} */ (host);
1046
+ const state = /** @type {SlotState | undefined} */ (h[SLOT_STATE]);
1047
+ if (!state) return;
1048
+ const inst = h[Symbol.for('webjs.instance')];
1049
+ const rendered = Symbol.for('webjs.instance') in h;
1050
+ // Gate on the RECORDED connect branch (adoptSSRAssignments sets
1051
+ // state.adopted), never on structural re-detection: a bypass write can
1052
+ // itself carry a rendered-looking chunk (slot[data-webjs-light]
1053
+ // [data-projection] under plain wrappers) and would spoof a structural
1054
+ // check, suppressing the fold for unrelated writes in the same batch.
1055
+ // The flag is also free per reconnect where the structural query walked
1056
+ // the subtree.
1057
+ const adoptedMarkup = !rendered && state.adopted === true;
1058
+ let changed = false;
1059
+ for (const node of Array.from(host.childNodes)) {
1060
+ if (node === h[PARK]) continue;
1061
+ if (state.authored.indexOf(node) !== -1) {
1062
+ // Authored but physically a direct host child: a bypass MOVE made
1063
+ // while disconnected pulled it out of its slot. Re-apply so the
1064
+ // placement step repairs it.
1065
+ changed = true;
1066
+ continue;
1067
+ }
1068
+ // Skip the renderer's own top-level nodes. With an instance, ownership
1069
+ // is checked via the bookends. With an EXPLICIT null instance (the
1070
+ // non-template render path sets host[INSTANCE] = null: render() returned
1071
+ // a string / number / array) the renderer's text output IS the direct
1072
+ // host children, so folding is skipped entirely, like
1073
+ // drainRendererBackstop's no-instance guard. A host that has NEVER
1074
+ // rendered (the symbol is absent: moved before its deferred first
1075
+ // render) has no CLIENT-renderer output, so a disconnected-window
1076
+ // bypass write IS folded UNLESS the host carries adopted
1077
+ // framework-rendered markup (SSR/hydration before the deferred first
1078
+ // render, or a first render that threw): folding THAT subtree would
1079
+ // push template wrappers into the record and brick placement on a
1080
+ // HierarchyRequestError.
1081
+ if (rendered && (!inst || instanceOwns(inst, node))) continue;
1082
+ if (!rendered && adoptedMarkup) continue;
1083
+ FRAMEWORK_DETACHED.add(node);
1084
+ state.authored.push(node);
1085
+ changed = true;
1086
+ }
1087
+ if (changed) applySlotAssignments(host);
1088
+ }
1089
+
1090
+ /**
1091
+ * True when `node` is renderer-owned: it is one of the instance's bookend
1092
+ * markers (`wjm-s` / `wjm-e`) or sits between them. Object-identity check
1093
+ * against the marker refs the instance holds, never comment-text sniffing.
1094
+ */
1095
+ function instanceOwns(inst, node) {
1096
+ if (!inst || !inst.startNode || !inst.endNode) return false;
1097
+ if (node === inst.startNode || node === inst.endNode) return true;
1098
+ for (let n = inst.startNode.nextSibling; n && n !== inst.endNode; n = n.nextSibling) {
1099
+ if (n === node) return true;
1100
+ }
1101
+ return false;
1102
+ }
1103
+
1104
+ /** The host's hidden park element, created + attached lazily. */
1105
+ function parkFor(host) {
1106
+ const h = /** @type {any} */ (host);
1107
+ let park = h[PARK];
1108
+ if (!park || !park.isConnected) {
1109
+ if (!park) {
1110
+ park = host.ownerDocument.createElement('wj-slot-park');
1111
+ park.setAttribute('hidden', '');
1112
+ park.style.display = 'none';
1113
+ h[PARK] = park;
1114
+ }
1115
+ // Attach inside the host, after the rendered output, via the native path
1116
+ // (never through the patched appendChild).
1117
+ withRendererWrites(host, () => N_appendChild.call(host, park));
1118
+ }
1119
+ return park;
1120
+ }
1121
+
1122
+ /**
1123
+ * True for a REAL platform Node from any realm: a same-realm Node passes
1124
+ * instanceof; a cross-realm Node passes via its own realm's constructor.
1125
+ * KNOWN EDGE: a node from a DISCARDED iframe realm (defaultView null) and
1126
+ * any cross-realm DOCUMENT (ownerDocument is null on a Document by spec)
1127
+ * fail both arms where native would adopt or HierarchyRequestError:
1128
+ * appendChild-shaped calls throw TypeError, and the variadic
1129
+ * string-accepting calls stringify to text. Both outcomes are strictly
1130
+ * safer than admitting an unverifiable object into the record.
1131
+ *
1132
+ * @param {any} n
408
1133
  * @returns {boolean}
409
1134
  */
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);
1135
+ function isRealmNode(n) {
1136
+ return (
1137
+ n instanceof Node ||
1138
+ Boolean(
1139
+ n &&
1140
+ typeof n === 'object' &&
1141
+ /** @type {any} */ (n).ownerDocument &&
1142
+ /** @type {any} */ (n).ownerDocument.defaultView &&
1143
+ n instanceof /** @type {any} */ (n).ownerDocument.defaultView.Node,
1144
+ )
1145
+ );
415
1146
  }
416
1147
 
417
1148
  /**
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.
1149
+ * Expand one argument of a DOM insertion call into a flat node list. A
1150
+ * DocumentFragment is DRAINED (native contract: the fragment ends empty) and
1151
+ * its children returned; a string becomes a Text node when `allowString`
1152
+ * (append / prepend / replaceChildren accept strings, appendChild does not).
423
1153
  *
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).
1154
+ * @param {Element} host
1155
+ * @param {any} arg
1156
+ * @param {boolean} allowString
1157
+ * @returns {Node[]}
1158
+ */
1159
+ function expandArg(host, arg, allowString) {
1160
+ // WebIDL (Node or DOMString) coercion: append/prepend/replaceChildren
1161
+ // stringify ANY argument that is not a real platform Node (a number, null,
1162
+ // an object, even a duck-typed fake with a numeric nodeType); native
1163
+ // host.append(42) appends the text "42" and host.append({}) appends
1164
+ // "[object Object]".
1165
+ if (allowString && !isRealmNode(arg)) {
1166
+ // A template literal performs exactly ES ToString (ToPrimitive with hint
1167
+ // "string": toString before valueOf, and a Symbol THROWS TypeError),
1168
+ // matching WebIDL DOMString conversion; '' + x would use hint "default"
1169
+ // (valueOf first) and diverge for objects overriding both.
1170
+ return [host.ownerDocument.createTextNode(`${/** @type {any} */ (arg)}`)];
1171
+ }
1172
+ // Non-string path (appendChild / insertBefore / replaceChild): reject any
1173
+ // non-platform-node BEFORE the fragment branch, or a duck-typed
1174
+ // {nodeType: 11} fake would bypass guardInsertable entirely.
1175
+ if (!isRealmNode(arg)) {
1176
+ throw new TypeError('Failed to execute insertion on the host: parameter is not of type Node.');
1177
+ }
1178
+ if (arg && arg.nodeType === 11) {
1179
+ const kids = Array.from(arg.childNodes);
1180
+ // Guard BEFORE draining: a cycle error must leave the fragment intact
1181
+ // (native throws with the fragment untouched).
1182
+ guardInsertable(host, kids);
1183
+ for (const k of kids) N_removeChild.call(arg, k);
1184
+ return kids;
1185
+ }
1186
+ guardInsertable(host, [/** @type {Node} */ (arg)]);
1187
+ return [/** @type {Node} */ (arg)];
1188
+ }
1189
+
1190
+ /**
1191
+ * Throw `HierarchyRequestError` (native parity) if any node would create a
1192
+ * cycle: the host itself, or an ancestor of the host. Checked BEFORE the record
1193
+ * is mutated, so a bad insert leaves `authored` untouched, like native.
428
1194
  *
429
1195
  * @param {Element} host
430
- * @param {string | null} name
431
- * @param {Node | Node[] | string | null} value
1196
+ * @param {Node[]} nodes
432
1197
  */
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);
1198
+ function guardInsertable(host, nodes) {
1199
+ guardCycle(host, nodes);
1200
+ for (const n of nodes) {
1201
+ // Node-TYPE validity is DOM pre-insert step 4, AFTER the ref's
1202
+ // NotFoundError (step 3): callers run this via expandArg once the ref
1203
+ // checks passed. A non-insertable type (an Attr, a Document, a doctype)
1204
+ // is a HierarchyRequestError. Realm validity (TypeError for a
1205
+ // duck-typed fake, which would otherwise mutate the record and wedge
1206
+ // every later apply) is checked by callers BEFORE any DOM step, per
1207
+ // WebIDL argument conversion.
1208
+ if (!isRealmNode(n)) {
1209
+ throw new TypeError('Failed to execute insertion on the host: parameter is not of type Node.');
1210
+ }
1211
+ const t = n.nodeType;
1212
+ if (t !== 1 && t !== 3 && t !== 4 && t !== 7 && t !== 8 && t !== 11) {
1213
+ throw new DOMException(
1214
+ 'Failed to execute insertion on the host: the node type may not be inserted here.',
1215
+ 'HierarchyRequestError',
1216
+ );
1217
+ }
1218
+ }
1219
+ }
1220
+
1221
+ /**
1222
+ * DOM pre-insert step 2, the cycle check: run after ALL WebIDL argument
1223
+ * conversions and before the ref's NotFoundError (step 3).
1224
+ *
1225
+ * @param {Element} host
1226
+ * @param {Node[]} nodes
1227
+ */
1228
+ function guardCycle(host, nodes) {
1229
+ for (const n of nodes) {
1230
+ if (n === host || (n.nodeType === 1 && /** @type {Element} */ (n).contains(host))) {
1231
+ throw new DOMException(
1232
+ 'Failed to execute insertion on the host: the new child contains the parent.',
1233
+ 'HierarchyRequestError',
1234
+ );
1235
+ }
1236
+ }
1237
+ }
1238
+
1239
+ /**
1240
+ * Splice `nodes` into `authored` before `ref` (a node already in authored) or
1241
+ * at the end when `ref` is null. Nodes already present are removed from their
1242
+ * current position first (native move semantics for a re-inserted child).
1243
+ *
1244
+ * @param {SlotState} state
1245
+ * @param {Node[]} nodes
1246
+ * @param {Node | null} ref
1247
+ */
1248
+ function authoredSplice(state, nodes, ref) {
1249
+ const a = state.authored;
1250
+ for (const n of nodes) {
1251
+ const i = a.indexOf(n);
1252
+ if (i !== -1) a.splice(i, 1);
1253
+ }
1254
+ let at = ref == null ? a.length : a.indexOf(ref);
1255
+ if (at === -1) at = a.length;
1256
+ a.splice(at, 0, ...nodes);
440
1257
  }
441
1258
 
442
1259
  /**
1260
+ * True when `el` is, or sits inside, an AUTHORED node of this host: such an
1261
+ * element is CONTENT (a chunk the author wrote or moved in), never one of
1262
+ * the host's own rendered slots, no matter what attributes it carries.
1263
+ *
1264
+ * Exported for the router's own-slot collection, which must apply the SAME
1265
+ * invariant when picking reprojection targets.
1266
+ *
1267
+ * @param {Element} host
1268
+ * @param {Element} el
1269
+ * @returns {boolean}
1270
+ */
1271
+ export function isAuthoredContentSlot(host, el) {
1272
+ const state = /** @type {SlotState | undefined} */ (
1273
+ /** @type {any} */ (host)[SLOT_STATE]
1274
+ );
1275
+ if (!state) return false;
1276
+ return isInsideAuthored(state, host, el);
1277
+ }
1278
+
1279
+ /** @param {SlotState} state @param {Element} host @param {Element} el */
1280
+ function isInsideAuthored(state, host, el) {
1281
+ for (let p = /** @type {Node | null} */ (el); p && p !== host; p = p.parentNode) {
1282
+ if (state.authored.indexOf(p) !== -1) return true;
1283
+ }
1284
+ return false;
1285
+ }
1286
+
1287
+ /**
1288
+ * True when an authored node is still VIRTUALLY a child of the host: it
1289
+ * physically sits in one of the host's own slots, the park, or the host
1290
+ * itself, or the framework holds it detached as a record value (a rescued
1291
+ * closed-slot child). A node the author moved elsewhere out-of-band (into a
1292
+ * fragment, another element) is NOT a child anymore even while the record
1293
+ * still lists it, and native removeChild / replaceChild / insertBefore-ref
1294
+ * answer that with NotFoundError.
1295
+ *
1296
+ * @param {Element} host
1297
+ * @param {Node} node
1298
+ * @returns {boolean}
1299
+ */
1300
+ function isVirtualChild(host, node) {
1301
+ if (FRAMEWORK_DETACHED.has(node)) return true;
1302
+ const p = node.parentNode;
1303
+ if (p == null) return false;
1304
+ if (p === host) return true;
1305
+ if (p === /** @type {any} */ (host)[PARK]) return true;
1306
+ const state = /** @type {SlotState | undefined} */ (
1307
+ /** @type {any} */ (host)[SLOT_STATE]
1308
+ );
1309
+ // lastSnapshot membership proves ownership on its own (a detached forwarded
1310
+ // slot's structural isOwnSlot would wrongly veto it); a contained slot still
1311
+ // needs isOwnSlot so a foreign torn-down slot is not claimed.
1312
+ return (
1313
+ p.nodeType === 1 &&
1314
+ /** @type {Element} */ (p).tagName === 'SLOT' &&
1315
+ /** @type {Element} */ (p).hasAttribute(LIGHT_SLOT_ATTR) &&
1316
+ ((state && state.lastSnapshot.has(/** @type {HTMLSlotElement} */ (p))) ||
1317
+ (host.contains(p) && isOwnSlot(host, /** @type {Element} */ (p))))
1318
+ );
1319
+ }
1320
+
1321
+ /** The empty set handed to resync when no record op is pending. */
1322
+ const EMPTY_NODE_SET = new Set();
1323
+
1324
+ /**
1325
+ * WebIDL for the variadic insertion methods converts EVERY argument before
1326
+ * any node is moved: phase 1 validates each argument (a Symbol or fake
1327
+ * throws with every fragment still intact), phase 2 drains fragments and
1328
+ * builds the flat node list.
1329
+ *
443
1330
  * @param {Element} host
444
- * @param {Node | Node[] | string | null} value
1331
+ * @param {any[]} args
445
1332
  * @returns {Node[]}
446
1333
  */
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)] : [];
1334
+ function convertVariadicArgs(host, args) {
1335
+ // Phase 1 is PURE WebIDL conversion, left to right, with NO DOM validity
1336
+ // interleaved: native converts every argument (running ToString side
1337
+ // effects, throwing on a Symbol) before any operation-body step, so a
1338
+ // later argument's conversion TypeError must preempt an earlier
1339
+ // argument's HierarchyRequestError.
1340
+ /** @type {Array<{ text?: string, frag?: DocumentFragment, node?: Node }>} */
1341
+ const converted = [];
1342
+ for (const arg of args) {
1343
+ if (!isRealmNode(arg)) {
1344
+ converted.push({ text: `${arg}` }); // Symbol throws here, nothing drained
1345
+ } else if (arg.nodeType === 11) {
1346
+ converted.push({ frag: /** @type {DocumentFragment} */ (arg) });
1347
+ } else {
1348
+ converted.push({ node: arg });
1349
+ }
1350
+ }
1351
+ // Phase 2: DOM validity for every converted argument, THEN the build.
1352
+ for (const c of converted) {
1353
+ if (c.frag) guardCycle(host, Array.from(c.frag.childNodes));
1354
+ else if (c.node) guardInsertable(host, [c.node]);
1355
+ }
1356
+ // The build mirrors native's "converting nodes into a node": append each
1357
+ // converted item to a SCRATCH fragment in argument order. Appending a
1358
+ // plain-node argument detaches it from wherever it currently sits,
1359
+ // INCLUDING a later fragment argument, so append(a, fragContaining(a))
1360
+ // nets [a, ...fragRest] exactly like native; a repeated Node argument is
1361
+ // physically moved, netting keep-LAST order; and the record can never
1362
+ // receive a duplicate entry (scratch children are unique by construction).
1363
+ // ACCEPTED DIVERGENCE, documented here as its canonical spot: detaching a
1364
+ // connected argument fires its [CEReactions] callbacks MID-call, where
1365
+ // native defers them to the end of the outer variadic operation (a
1366
+ // userland polyfill has no reactions queue). The net DOM converges
1367
+ // through the latch + record ops; the one observable divergence is a
1368
+ // same-host-mutating disconnect callback racing replaceChildren's
1369
+ // wholesale displacement. (Connected variadic arguments also fire their
1370
+ // disconnect reactions on detach, matching native's own per-node moves.)
1371
+ const scratch = host.ownerDocument.createDocumentFragment();
1372
+ for (const c of converted) {
1373
+ if (c.text !== undefined) {
1374
+ N_appendChild.call(scratch, host.ownerDocument.createTextNode(c.text));
1375
+ } else if (c.frag) {
1376
+ const kids = Array.from(c.frag.childNodes);
1377
+ guardInsertable(host, kids);
1378
+ for (const k of kids) N_appendChild.call(scratch, k);
1379
+ } else if (c.node) {
1380
+ // DEFENSE-IN-DEPTH early throw (the LOAD-BEARING check is the final
1381
+ // assembled guard below): a reaction fired by an earlier argument's
1382
+ // detach can reparent the HOST into this one; catching it here just
1383
+ // surfaces the error before this arg enters the scratch.
1384
+ guardCycle(host, [c.node]);
1385
+ N_appendChild.call(scratch, c.node);
1386
+ }
1387
+ }
1388
+ const nodes = Array.from(scratch.childNodes);
1389
+ // The LOAD-BEARING assembled-validity check (DOM pre-insert runs validity
1390
+ // AFTER the conversion step): whatever mid-conversion reactions did,
1391
+ // including a LATER argument's reaction poisoning an EARLIER scratch
1392
+ // child, the returned list never contains a host-containing node, so the
1393
+ // CONVERSION window cannot feed one into the record. (The record-to-
1394
+ // placement window is separately shielded by the placement-time skip in
1395
+ // applyActualAssignment.)
1396
+ guardCycle(host, nodes);
1397
+ for (const n of nodes) N_removeChild.call(scratch, n);
1398
+ return nodes;
1399
+ }
1400
+
1401
+ /**
1402
+ * Commit an authored mutation: record it, re-derive, re-place. `touched`
1403
+ * lists the nodes this op inserted, moved, or removed, so the resync step
1404
+ * honours the record's position for exactly those nodes (an expressed move)
1405
+ * while adopting physical order for everything else.
1406
+ *
1407
+ * @param {Element} host
1408
+ * @param {SlotState} state
1409
+ * @param {Node[]} [touched]
1410
+ */
1411
+ function commitAuthored(host, state, touched) {
1412
+ // UNION under the latch: a nested record op cannot run its own pass, so
1413
+ // its touched set must survive until the outer loop's next iteration; a
1414
+ // second nested op must not clobber the first one's order authority.
1415
+ if (state.applying && state.pendingRecordNodes) {
1416
+ for (const n of touched || []) state.pendingRecordNodes.add(n);
1417
+ } else {
1418
+ state.pendingRecordNodes = new Set(touched || []);
1419
+ }
1420
+ // An author record op on a node ends its self-heal adoption: once the
1421
+ // author takes over, the node routes by its own attribute again (also
1422
+ // covers an attribute change made while the sensors were down).
1423
+ if (state.adoptedKey && touched) {
1424
+ for (const n of touched) state.adoptedKey.delete(n);
452
1425
  }
453
- if (Array.isArray(value)) return value.filter(Boolean);
454
- return [value];
1426
+ repartition(state);
1427
+ applySlotAssignments(host);
1428
+ }
1429
+
1430
+ /**
1431
+ * Install the per-instance interception on a LIGHT-DOM host so native DOM
1432
+ * writes drive the slot record. Own data properties / accessors shadow the
1433
+ * prototype methods; installed once, never removed (so a mutation while the
1434
+ * host is disconnected still updates the record). No-op in shadow DOM.
1435
+ *
1436
+ * @param {Element} host
1437
+ */
1438
+ export function installSlotInterception(host) {
1439
+ if (!inBrowser) return;
1440
+ const h = /** @type {any} */ (host);
1441
+ if (h[INTERCEPTED]) return;
1442
+ captureNatives();
1443
+ h[INTERCEPTED] = true;
1444
+ const state = ensureSlotState(host);
1445
+
1446
+ h.appendChild = function (node) {
1447
+ if (h[RENDERING]) return N_appendChild.call(this, node);
1448
+ const nodes = expandArg(host, node, false);
1449
+ for (const n of nodes) FRAMEWORK_DETACHED.add(n); // prune-exempt until placed
1450
+ authoredSplice(state, nodes, null);
1451
+ commitAuthored(host, state, nodes);
1452
+ return node;
1453
+ };
1454
+
1455
+ h.insertBefore = function (node, ref) {
1456
+ if (h[RENDERING]) return N_insertBefore.call(this, node, ref);
1457
+ // WebIDL converts arguments LEFT TO RIGHT and DOM pre-insert validity
1458
+ // runs the cycle (HierarchyRequestError) step before the ref
1459
+ // (NotFoundError) step: validate parameter 1's type and insertability
1460
+ // FIRST, without draining a fragment (the drain happens in expandArg
1461
+ // only after every validity check passed, so an error leaves the
1462
+ // fragment intact like native).
1463
+ if (!isRealmNode(node)) {
1464
+ throw new TypeError('Failed to execute insertBefore on the host: parameter 1 is not of type Node.');
1465
+ }
1466
+ if (ref != null && !isRealmNode(ref)) {
1467
+ throw new TypeError('Failed to execute insertBefore on the host: parameter 2 is not of type Node.');
1468
+ }
1469
+ // Both conversions done; now DOM validity in step order (cycle, then the
1470
+ // ref's NotFoundError below, then node-type inside expandArg).
1471
+ guardCycle(host, node.nodeType === 11 ? Array.from(node.childNodes) : [node]);
1472
+ // A non-null ref MUST be an assigned child (native throws NotFoundError
1473
+ // otherwise), checked before the self-ref no-op so insertBefore(x, x) on
1474
+ // a NON-child still throws like native.
1475
+ if (
1476
+ ref != null &&
1477
+ (state.authored.indexOf(ref) === -1 || !isVirtualChild(host, ref))
1478
+ ) {
1479
+ throw new DOMException(
1480
+ 'insertBefore: reference node is not an assigned child of this host',
1481
+ 'NotFoundError',
1482
+ );
1483
+ }
1484
+ // insertBefore(n, n) with n an existing child is a native no-op.
1485
+ if (node === ref) return node;
1486
+ const nodes = expandArg(host, node, false);
1487
+ for (const n of nodes) FRAMEWORK_DETACHED.add(n); // prune-exempt until placed
1488
+ authoredSplice(state, nodes, ref || null);
1489
+ commitAuthored(host, state, nodes);
1490
+ return node;
1491
+ };
1492
+
1493
+ h.removeChild = function (node) {
1494
+ if (h[RENDERING]) return N_removeChild.call(this, node);
1495
+ const i = state.authored.indexOf(node);
1496
+ if (i === -1) return N_removeChild.call(this, node);
1497
+ // Record-listed but physically moved elsewhere out-of-band: native
1498
+ // answers "not a child" (the record heals on the next apply).
1499
+ if (!isVirtualChild(host, node)) {
1500
+ throw new DOMException(
1501
+ 'removeChild: the node is not an assigned child of this host',
1502
+ 'NotFoundError',
1503
+ );
1504
+ }
1505
+ state.authored.splice(i, 1);
1506
+ commitAuthored(host, state, [node]);
1507
+ return node;
1508
+ };
1509
+
1510
+ h.replaceChild = function (newNode, oldNode) {
1511
+ if (h[RENDERING]) return N_replaceChild.call(this, newNode, oldNode);
1512
+ const i = state.authored.indexOf(oldNode);
1513
+ if (i === -1) return N_replaceChild.call(this, newNode, oldNode);
1514
+ // WebIDL converts parameters left to right and the cycle check precedes
1515
+ // the NotFound check: validate parameter 1 first, without draining.
1516
+ if (!isRealmNode(newNode)) {
1517
+ throw new TypeError('Failed to execute replaceChild on the host: parameter 1 is not of type Node.');
1518
+ }
1519
+ guardCycle(host, newNode.nodeType === 11 ? Array.from(newNode.childNodes) : [newNode]);
1520
+ if (!isVirtualChild(host, oldNode)) {
1521
+ throw new DOMException(
1522
+ 'replaceChild: the node to be replaced is not an assigned child of this host',
1523
+ 'NotFoundError',
1524
+ );
1525
+ }
1526
+ if (newNode === oldNode) return oldNode; // native no-op
1527
+ const nodes = expandArg(host, newNode, false); // guards cycle before draining
1528
+ for (const n of nodes) FRAMEWORK_DETACHED.add(n); // prune-exempt until placed
1529
+ // Remove any incoming node already authored (so it MOVES to the new slot),
1530
+ // but never oldNode itself: it is the replacement target, so skipping it
1531
+ // keeps `at` valid even for the pathological replaceChild(fragmentWithOld,
1532
+ // old) input, avoiding a splice(-1) that would corrupt an unrelated sibling.
1533
+ for (const n of nodes) {
1534
+ if (n === oldNode) continue;
1535
+ const j = state.authored.indexOf(n);
1536
+ if (j !== -1) state.authored.splice(j, 1);
1537
+ }
1538
+ const at = state.authored.indexOf(oldNode);
1539
+ state.authored.splice(at, 1, ...nodes);
1540
+ commitAuthored(host, state, [...nodes, oldNode]);
1541
+ return oldNode;
1542
+ };
1543
+
1544
+ h.append = function (...args) {
1545
+ if (h[RENDERING]) return N_append.apply(this, args);
1546
+ const nodes = convertVariadicArgs(host, args);
1547
+ for (const n of nodes) FRAMEWORK_DETACHED.add(n); // prune-exempt until placed
1548
+ authoredSplice(state, nodes, null);
1549
+ commitAuthored(host, state, nodes);
1550
+ };
1551
+
1552
+ h.prepend = function (...args) {
1553
+ if (h[RENDERING]) return N_prepend.apply(this, args);
1554
+ const nodes = convertVariadicArgs(host, args);
1555
+ for (const n of nodes) FRAMEWORK_DETACHED.add(n); // prune-exempt until placed
1556
+ // Remove any incoming node from its current position, then insert at the
1557
+ // FRONT via unshift. Passing `authored[0]` as an authoredSplice ref would be
1558
+ // wrong, because that ref is captured before the incoming-removal, so
1559
+ // prepending the current first child loses the ref and appends at the end.
1560
+ for (const n of nodes) {
1561
+ const j = state.authored.indexOf(n);
1562
+ if (j !== -1) state.authored.splice(j, 1);
1563
+ }
1564
+ state.authored.unshift(...nodes);
1565
+ commitAuthored(host, state, nodes);
1566
+ };
1567
+
1568
+ h.replaceChildren = function (...args) {
1569
+ if (h[RENDERING]) return N_replaceChildren.apply(this, args);
1570
+ const nodes = convertVariadicArgs(host, args);
1571
+ for (const n of nodes) FRAMEWORK_DETACHED.add(n); // prune-exempt until placed
1572
+ const displaced = state.authored;
1573
+ state.authored = nodes.slice();
1574
+ commitAuthored(host, state, [...displaced, ...nodes]);
1575
+ };
1576
+
1577
+ Object.defineProperty(h, 'innerHTML', {
1578
+ configurable: true,
1579
+ get() {
1580
+ return INNER_HTML_DESC.get.call(this);
1581
+ },
1582
+ set(str) {
1583
+ if (h[RENDERING]) {
1584
+ INNER_HTML_DESC.set.call(this, str);
1585
+ return;
1586
+ }
1587
+ // Parse in a DIV (the "in body" fragment context a custom-element host
1588
+ // gets natively): a <template> retains table-section tokens (<td>,
1589
+ // <tr>) that the real host context drops to text.
1590
+ const tmp = host.ownerDocument.createElement('div');
1591
+ // innerHTML IS [LegacyNullToEmptyString]: null maps to the empty
1592
+ // string (the common clear idiom must clear, not insert the text
1593
+ // "null"); undefined stringifies; a Symbol throws (ToString).
1594
+ INNER_HTML_DESC.set.call(tmp, str === null ? '' : `${str}`);
1595
+ const nodes = Array.from(tmp.childNodes);
1596
+ for (const n of nodes) FRAMEWORK_DETACHED.add(n); // prune-exempt until placed
1597
+ const displaced = state.authored;
1598
+ state.authored = nodes;
1599
+ commitAuthored(host, state, [...displaced, ...nodes]);
1600
+ },
1601
+ });
1602
+
1603
+ Object.defineProperty(h, 'textContent', {
1604
+ configurable: true,
1605
+ get() {
1606
+ return TEXT_CONTENT_DESC.get.call(this);
1607
+ },
1608
+ set(str) {
1609
+ if (h[RENDERING]) {
1610
+ TEXT_CONTENT_DESC.set.call(this, str);
1611
+ return;
1612
+ }
1613
+ // Node.textContent is a NULLABLE DOMString? (LegacyNullToEmptyString
1614
+ // belongs to innerHTML, not here): WebIDL converts undefined to null
1615
+ // for nullable types, so BOTH null and undefined EMPTY, verified
1616
+ // against all three engines. A Symbol still throws (ToString).
1617
+ const nodes =
1618
+ str == null || str === ''
1619
+ ? []
1620
+ : [host.ownerDocument.createTextNode(`${str}`)];
1621
+ for (const n of nodes) FRAMEWORK_DETACHED.add(n); // prune-exempt until placed
1622
+ const displaced = state.authored;
1623
+ state.authored = nodes;
1624
+ commitAuthored(host, state, [...displaced, ...nodes]);
1625
+ },
1626
+ });
455
1627
  }
456
1628
 
457
1629
  // ---------------------------------------------------------------------------
@@ -460,8 +1632,8 @@ function normalizeSlotValue(host, value) {
460
1632
 
461
1633
  /**
462
1634
  * 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
1635
+ * renderer's slot parts call this after the template commits, and the
1636
+ * interception + sensors call it after an authored mutation. Idempotent and cheap on
465
1637
  * no-change passes.
466
1638
  *
467
1639
  * 1. Collect the host's OWN slots (no other custom element between the
@@ -481,45 +1653,373 @@ export function applySlotAssignments(host) {
481
1653
  );
482
1654
  if (!state) return;
483
1655
 
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));
1656
+ // Re-entrancy latch: a custom-element reaction fired by a placement or
1657
+ // fallback removal (a disconnectedCallback doing host.appendChild) can
1658
+ // author-write while THIS pass is mid-flight; running a nested pass
1659
+ // would let the outer loop destroy the inner placements and desync
1660
+ // data-projection from the snapshot. The nested call returns after
1661
+ // flagging a re-run (its record splice already happened in the
1662
+ // interceptor), and the outer pass repeats until a full pass runs with
1663
+ // no re-entrant write. The end-of-pass source drain also rides this
1664
+ // loop instead of recursing.
1665
+ if (state.applying) {
1666
+ state.reapply = true;
1667
+ return;
489
1668
  }
1669
+ state.applying = true;
1670
+ try {
1671
+ do {
1672
+ state.reapply = false;
490
1673
 
491
- // 2. Group by current `name` attribute in document order.
492
- /** @type {Map<string|null, HTMLSlotElement[]>} */
493
- const groups = new Map();
494
- for (const slot of slots) {
495
- const name = keyOfName(slot.getAttribute('name'));
496
- let arr = groups.get(name);
497
- if (!arr) {
498
- arr = [];
499
- groups.set(name, arr);
500
- }
501
- arr.push(slot);
502
- }
503
-
504
- // 3. Assign per the first-wins rule.
505
- /** @type {HTMLSlotElement[]} */
506
- const slotsChanged = [];
507
- for (const [name, group] of groups) {
508
- const assigned = state.assignedByName.get(name) || [];
509
- for (let i = 0; i < group.length; i++) {
510
- const slot = group[i];
511
- if (i === 0 && assigned.length > 0) {
512
- if (applyActualAssignment(state, slot, assigned)) {
513
- slotsChanged.push(slot);
1674
+ // 0. Self-heal, prune, re-derive, in that order. First fold in what a
1675
+ // NON-record writer wrote inside our own actual slots (a parent
1676
+ // component's hole committed within projected content, or a library
1677
+ // operating on the assigned container): the apply step is the only
1678
+ // record writer, so physical-vs-snapshot divergence means someone else
1679
+ // wrote there, and destroying their nodes on this pass would be the
1680
+ // one-writer violation in reverse. Then prune the record of nodes the
1681
+ // author detached out from under us (an `el.remove()` on a projected
1682
+ // child, or a re-parent elsewhere): their parent is no longer one of our
1683
+ // own slots / the park, and we did not detach them ourselves. Then
1684
+ // re-derive from the surviving `authored` so a `slot=` change (or any
1685
+ // authored mutation) is reflected before placement.
1686
+ const pendingNodes = state.pendingRecordNodes || EMPTY_NODE_SET;
1687
+ state.pendingRecordNodes = undefined;
1688
+ resyncActualSlots(host, state, pendingNodes);
1689
+ pruneAuthored(host, state);
1690
+ repartition(state);
1691
+
1692
+ // 1. The host's own slots, document order. A slot that is BOUND but not yet
1693
+ // FINALIZED (its slot-part deferred finalize to a microtask: it carries
1694
+ // data-webjs-light from compile time but has neither a data-projection
1695
+ // stamp nor a harvested fallback frag) is EXCLUDED from placement this
1696
+ // pass: treating it as fallback-mode would destroy its un-harvested
1697
+ // fallback clone, and the finalize's own queued apply covers it one
1698
+ // microtask later. Its NAME still counts as rendered (pendingNames) so
1699
+ // the park step does not spuriously park (and bounce) its content.
1700
+ /** @type {HTMLSlotElement[]} */
1701
+ const slots = [];
1702
+ /** @type {Set<string|null>} */
1703
+ const pendingNames = new Set();
1704
+ for (const el of host.querySelectorAll(`slot[${LIGHT_SLOT_ATTR}]`)) {
1705
+ if (!isOwnSlot(host, el)) continue;
1706
+ // An own slot can NEVER live inside AUTHORED content: a slot element the
1707
+ // author moved or wrote into the host (another component's chunk, a
1708
+ // spoofed stamp) is inert content, exactly like a <slot> outside a
1709
+ // shadow tree natively. Collecting it would hand it assignments whose
1710
+ // nodes can CONTAIN it (HierarchyRequestError at placement).
1711
+ if (isInsideAuthored(state, host, el)) continue;
1712
+ if (
1713
+ !el.hasAttribute(PROJECTION_ATTR) &&
1714
+ !(SLOT_FALLBACK_FRAG in /** @type {any} */ (el))
1715
+ ) {
1716
+ pendingNames.add(keyOfName(el.getAttribute('name')));
1717
+ continue;
1718
+ }
1719
+ slots.push(/** @type {HTMLSlotElement} */ (el));
1720
+ }
1721
+
1722
+ // 2. Group by current `name` attribute in document order.
1723
+ /** @type {Map<string|null, HTMLSlotElement[]>} */
1724
+ const groups = new Map();
1725
+ for (const slot of slots) {
1726
+ const name = keyOfName(slot.getAttribute('name'));
1727
+ let arr = groups.get(name);
1728
+ if (!arr) {
1729
+ arr = [];
1730
+ groups.set(name, arr);
1731
+ }
1732
+ arr.push(slot);
1733
+ }
1734
+
1735
+ // 3. Assign per the first-wins rule; a node manually bound via `assign()`
1736
+ // routes to ITS slot element (native binds slottables to the receiving
1737
+ // element), everything else to the first slot of its name.
1738
+ /** @type {HTMLSlotElement[]} */
1739
+ const slotsChanged = [];
1740
+ for (const [name, group] of groups) {
1741
+ const assigned = state.assignedByName.get(name) || [];
1742
+ for (let i = 0; i < group.length; i++) {
1743
+ const slot = group[i];
1744
+ let own = assigned.filter((n) => {
1745
+ const m = manualSlotFor(state, n);
1746
+ return m ? m === slot : i === 0;
1747
+ });
1748
+ // Placement-time cycle shield (skip, never throw): a reaction
1749
+ // fired by an EARLIER slice's departure removal can reparent the
1750
+ // HOST into a node this slice is about to place; inserting it
1751
+ // would throw a native HierarchyRequestError out of the whole
1752
+ // apply pass. The filter runs BEFORE the actual/fallback
1753
+ // decision, so a fully poisoned slice correctly degrades to
1754
+ // FALLBACK instead of an actual-stamped empty slot, and the
1755
+ // poisoned nodes' prune exemptions are CLEARED so the next pass's
1756
+ // prune reaps them by their (outside-the-host) parent chain.
1757
+ const poisoned = own.filter(
1758
+ (n) => n === host || (n.nodeType === 1 && /** @type {Element} */ (n).contains(host)),
1759
+ );
1760
+ if (poisoned.length) {
1761
+ for (const n of poisoned) FRAMEWORK_DETACHED.delete(n);
1762
+ own = own.filter((n) => poisoned.indexOf(n) === -1);
1763
+ }
1764
+ if (own.length > 0) {
1765
+ if (applyActualAssignment(state, slot, own)) {
1766
+ slotsChanged.push(slot);
1767
+ }
1768
+ } else {
1769
+ if (applyFallback(state, slot)) slotsChanged.push(slot);
1770
+ }
514
1771
  }
515
- } else {
516
- if (applyFallback(state, slot)) slotsChanged.push(slot);
1772
+ }
1773
+
1774
+ // 4. Queue slotchange on slots whose assignment actually changed. Native
1775
+ // timing: assignment recomputes synchronously (placement above already
1776
+ // ran) but the slotchange EVENT is async and coalesced (one per slot per
1777
+ // microtask). Synchronous dispatch here would let an author mutation
1778
+ // inside a slotchange handler recurse into this writer mid-loop, and
1779
+ // would fire N events for an N-node loop; coalescing matches the spec.
1780
+ for (const slot of slotsChanged) queueSlotChange(state, slot);
1781
+
1782
+ // 5. Park authored nodes whose name matches no rendered own-slot. Native
1783
+ // shadow keeps an unassigned child connected but unrendered (a nested
1784
+ // custom element still upgrades and runs connectedCallback); a hidden
1785
+ // holding element inside the host reproduces that. Parked nodes have
1786
+ // parentNode === park, so the prune rule keeps them. The park is
1787
+ // RECONCILED to exactly the current unmatched set: a node that left the
1788
+ // record (or now matches a slot) is detached from the park, so a removed
1789
+ // parked child ends up isConnected === false like native removeChild.
1790
+ const matched = new Set(groups.keys());
1791
+ for (const name of pendingNames) matched.add(name);
1792
+ const shouldPark = new Set();
1793
+ for (const n of state.authored) {
1794
+ if (!matched.has(effectiveKeyOf(state, n))) shouldPark.add(n);
1795
+ }
1796
+ const existingPark = /** @type {any} */ (host)[PARK];
1797
+ if (existingPark) {
1798
+ for (const n of Array.from(existingPark.childNodes)) {
1799
+ if (!shouldPark.has(n)) N_removeChild.call(existingPark, n);
1800
+ }
1801
+ }
1802
+ if (shouldPark.size) {
1803
+ const park = parkFor(host);
1804
+ for (const n of shouldPark) {
1805
+ if (n.parentNode !== park) {
1806
+ FRAMEWORK_DETACHED.delete(n);
1807
+ withRendererWrites(host, () => N_appendChild.call(park, n));
1808
+ }
1809
+ }
1810
+ }
1811
+
1812
+ // LAST step: consume the records this apply's own placements generated,
1813
+ // while their containment evidence is still fresh. A host-to-slot
1814
+ // placement removal processed now sees contains === true and is retained
1815
+ // trivially, so it can never age into a stale record that a later rescue
1816
+ // makes indistinguishable from an author removal (the retention conjuncts
1817
+ // in processBackstop stay as a second line for any record that still
1818
+ // straddles). Running at the END keeps any recursive fold's inner apply
1819
+ // from racing this pass's park bookkeeping; recursion terminates because
1820
+ // a pure-placement batch marks nothing dirty and an inner apply leaves an
1821
+ // empty queue behind.
1822
+ // DEFENSE-IN-DEPTH, empirically not required by any suite-constructible
1823
+ // interleaving at this head (deleting it stays green): the window-close
1824
+ // drains and the retention conjuncts cover every reproduced sequence.
1825
+ // It stays because the stale-placement-record race was independently
1826
+ // traced by two reviews, is O(pending records) cheap, provably
1827
+ // terminates under the latch, and processing placement records with
1828
+ // fresh containment can only ever retain correctly.
1829
+ if (state.backstop) {
1830
+ const placementRecords = state.backstop.takeRecords();
1831
+ if (placementRecords.length) processBackstop(host, state, placementRecords);
1832
+ }
1833
+ } while (state.reapply);
1834
+ } finally {
1835
+ state.applying = false;
1836
+ }
1837
+ }
1838
+
1839
+ /**
1840
+ * Self-heal `authored` against a NON-record writer that wrote INSIDE one of
1841
+ * the host's own actual slots. Two such writers are legitimate: a parent
1842
+ * component whose hole was authored as this host's content (its child-part
1843
+ * marker projects into the slot, so a later array / template commit inserts
1844
+ * or removes nodes there with no interceptor in the way), and a third-party
1845
+ * library operating on the assigned container (the documented target for
1846
+ * generic DOM code). The apply step is the ONLY record-driven DOM writer, so
1847
+ * a slot whose physical childNodes diverge from its `lastSnapshot` was
1848
+ * written by someone else since the last apply; destroying those nodes on
1849
+ * this pass (the pre-fix behaviour) is the one-writer violation in reverse
1850
+ * and detaches DOM a live renderer part still points at.
1851
+ *
1852
+ * Reconciliation rule, per diverged slot. Order authority is NODE-scoped,
1853
+ * never pass-scoped (a pass-scoped rule made the outcome depend on which
1854
+ * trigger happened to run the apply):
1855
+ * - The PHYSICAL order of the slice is the base: a renderer part reordering
1856
+ * a keyed list inside the slot is never fought back, regardless of what
1857
+ * triggered this apply.
1858
+ * - Nodes the current record op TOUCHED (`pendingNodes`: inserted, moved, or
1859
+ * removed by the interceptor / assign() / router splice that triggered
1860
+ * this apply) are taken OUT of the base and re-anchored at their
1861
+ * record-implied position, so an author's expressed move (appendChild of
1862
+ * an existing child = move to end) is honoured; an op-REMOVED node is in
1863
+ * `pendingNodes` but no longer in the record, so it simply drops.
1864
+ * - Record nodes missing from the slot (a bypass move onto the host, a
1865
+ * genuine author detach) are also re-anchored; `pruneAuthored`, which runs
1866
+ * right after, decides their fate structurally by their current parent
1867
+ * (re-place vs drop), so no zombie is resurrected.
1868
+ *
1869
+ * @param {Element} host
1870
+ * @param {SlotState} state
1871
+ * @param {Set<Node>} pendingNodes
1872
+ */
1873
+ function resyncActualSlots(host, state, pendingNodes) {
1874
+ // lastSnapshot is a WeakMap (not iterable): walk the host's own APPLIED
1875
+ // actual slots instead, which are exactly the elements a snapshot exists
1876
+ // for.
1877
+ for (const el of host.querySelectorAll(
1878
+ `slot[${LIGHT_SLOT_ATTR}][${PROJECTION_ATTR}="${PROJECTION_ACTUAL}"]`,
1879
+ )) {
1880
+ if (!isOwnSlot(host, el)) continue;
1881
+ if (isInsideAuthored(state, host, el)) continue; // authored content, not a slot
1882
+ const slot = /** @type {HTMLSlotElement} */ (el);
1883
+ const snapshot = state.lastSnapshot.get(slot);
1884
+ if (!snapshot) continue;
1885
+ const physical = Array.from(slot.childNodes);
1886
+ if (arraysEqual(physical, snapshot)) continue;
1887
+ const a = state.authored;
1888
+ const key = keyOfName(slot.getAttribute('name'));
1889
+
1890
+ // Base: the slice in PHYSICAL order, minus op-touched nodes (re-anchored
1891
+ // below or op-removed). A physical node unknown to the record is a true
1892
+ // addition (renderer hole / library write) and folds in at its physical
1893
+ // position; when the container is a NAMED slot and the node's own
1894
+ // attribute would key it elsewhere, remember the container's key as the
1895
+ // node's ADOPTED key so repartition does not teleport it out of the
1896
+ // container it was written into.
1897
+ const merged = physical.filter((n) => !pendingNodes.has(n));
1898
+ for (const n of merged) {
1899
+ if (a.indexOf(n) !== -1) continue; // known to the record already
1900
+ if (effectiveKeyOf(state, n) !== key) {
1901
+ if (!state.adoptedKey) state.adoptedKey = new WeakMap();
1902
+ state.adoptedKey.set(n, key);
517
1903
  }
518
1904
  }
1905
+
1906
+ // Re-anchor every record node of this slice that is not already in the
1907
+ // base, in record order, each after the LAST base member that precedes
1908
+ // it in the record (start when none). Covers op-inserted/moved nodes and
1909
+ // record nodes missing from the slot (prune settles those right after).
1910
+ for (const n of a) {
1911
+ if (merged.indexOf(n) !== -1) continue;
1912
+ if (effectiveKeyOf(state, n) !== key) continue;
1913
+ const nIdx = a.indexOf(n);
1914
+ let at = 0;
1915
+ for (let i = 0; i < merged.length; i++) {
1916
+ const mIdx = a.indexOf(merged[i]);
1917
+ if (mIdx !== -1 && mIdx < nIdx) at = i + 1;
1918
+ }
1919
+ merged.splice(at, 0, n);
1920
+ }
1921
+
1922
+ // Replace the old slice with the merged one at the old slice's position.
1923
+ const involved = new Set(merged);
1924
+ for (const n of snapshot) involved.add(n);
1925
+ let at = -1;
1926
+ let seen = 0;
1927
+ for (const n of a) {
1928
+ if (involved.has(n)) {
1929
+ if (at === -1) at = seen;
1930
+ continue;
1931
+ }
1932
+ seen += 1;
1933
+ }
1934
+ const rest = a.filter((n) => !involved.has(n));
1935
+ if (at === -1 || at > rest.length) at = rest.length;
1936
+ rest.splice(at, 0, ...merged);
1937
+ state.authored = rest;
1938
+ // The snapshot itself is settled by the placement step this pass.
519
1939
  }
1940
+ }
520
1941
 
521
- // 4. Dispatch slotchange on slots whose assignment actually changed.
522
- for (const slot of slotsChanged) fireSlotChange(slot);
1942
+ /**
1943
+ * Prune `authored` of nodes the author detached out from under the record: a
1944
+ * node whose parent is neither one of the host's own actual slots nor the park,
1945
+ * and which the framework did not itself detach (capture / teardown rescue mark
1946
+ * such nodes so they survive the parentless window before (re)placement). This
1947
+ * closes the zombie-child resurrection (`el.remove()` on a projected node) and
1948
+ * cross-host theft: the ownership question is answered structurally by the
1949
+ * node's real parent, never by stale bookkeeping.
1950
+ *
1951
+ * @param {Element} host
1952
+ * @param {SlotState} state
1953
+ */
1954
+ function pruneAuthored(host, state) {
1955
+ const park = /** @type {any} */ (host)[PARK];
1956
+ state.authored = state.authored.filter((n) => {
1957
+ if (FRAMEWORK_DETACHED.has(n)) return true;
1958
+ const p = n.parentNode;
1959
+ if (p == null) return false;
1960
+ // A DIRECT host child is still ours: a raw bypass move pulled it out of
1961
+ // its slot onto the host (native: a host child stays assigned), and the
1962
+ // physically-verifying placement step re-places it this same pass.
1963
+ if (p === host) return true;
1964
+ if (p === park) return true;
1965
+ // The slot-parent keep requires the slot to be recognizably OURS.
1966
+ // lastSnapshot membership is DIRECT proof this host applied that slot,
1967
+ // so it stands alone (covering our own detached slots: a cache()-stashed
1968
+ // branch, a torn-down conditional, or a FORWARDED slot the re-render
1969
+ // detached, whose structural isOwnSlot walk would wrongly hit the child
1970
+ // component between it and this host). A merely-CONTAINED slot still
1971
+ // needs isOwnSlot, so the apply never steals a node back from an
1972
+ // unrelated component's torn-down slot the author moved it into (the
1973
+ // bare isOwnSlot walk is vacuously true on any fully detached chain).
1974
+ if (
1975
+ p.nodeType === 1 &&
1976
+ /** @type {Element} */ (p).tagName === 'SLOT' &&
1977
+ /** @type {Element} */ (p).hasAttribute(LIGHT_SLOT_ATTR) &&
1978
+ (state.lastSnapshot.has(/** @type {HTMLSlotElement} */ (p)) ||
1979
+ (host.contains(p) && isOwnSlot(host, /** @type {Element} */ (p))))
1980
+ ) {
1981
+ return true;
1982
+ }
1983
+ return false;
1984
+ });
1985
+ // The manual-assignment overlay needs no pruning here: it holds nodes via
1986
+ // WeakRef (native holds manually assigned slottables weakly), so a removed
1987
+ // node is not leaked, and an entry for a node assigned BEFORE it becomes a
1988
+ // host child stays honoured when the node is later appended (the native
1989
+ // assign-first, append-later ordering).
1990
+ }
1991
+
1992
+ /**
1993
+ * True when this host's current markup is FRAMEWORK-RENDERED output rather
1994
+ * than author-written children: a rendered light template carries the
1995
+ * framework's own `slot[data-webjs-light]` elements, an attribute only the
1996
+ * renderer / SSR ever stamps (own-slot filtered so a nested serialized
1997
+ * component inside genuinely-authored children does not misfire; and
1998
+ * `data-wj-host` is NOT usable here, since connectedCallback stamps it on
1999
+ * every light host before this check runs). The connectedCallback branch
2000
+ * chooser uses this STRUCTURAL signal to pick adopt-not-capture for a
2001
+ * framework-rendered subtree, so a back/forward snapshot restore
2002
+ * (post-hydration HTML, no `webjs-hydrate` marker) adopts the projected
2003
+ * children instead of hoovering the rendered tree into `authored` (the #1006
2004
+ * duplication shape on the restore path).
2005
+ *
2006
+ * @param {Element} host
2007
+ * @returns {boolean}
2008
+ */
2009
+ export function hasFrameworkRenderedSubtree(host) {
2010
+ if (!inBrowser) return false;
2011
+ // BOTH attributes are required. data-webjs-light alone is stamped at
2012
+ // TEMPLATE COMPILE time on every <slot> in every html template, including a
2013
+ // slot FORWARDED as an authored child of a nested component tag
2014
+ // (html`<inner-shell><slot>fallback</slot></inner-shell>`), so matching it
2015
+ // alone would misfire on the forwarding shape at a client-side first mount
2016
+ // and adopt (discard) the forwarded slot. data-projection is stamped only
2017
+ // when the framework has PLACED the slot (SSR substitution or the apply
2018
+ // step), so light + projection together mean genuinely rendered output.
2019
+ for (const el of host.querySelectorAll(`slot[${LIGHT_SLOT_ATTR}][${PROJECTION_ATTR}]`)) {
2020
+ if (isOwnSlot(host, el)) return true;
2021
+ }
2022
+ return false;
523
2023
  }
524
2024
 
525
2025
  /**
@@ -527,17 +2027,66 @@ export function applySlotAssignments(host) {
527
2027
  * sits between them. A slot nested inside a child custom element belongs
528
2028
  * to THAT component and is applied from its own record.
529
2029
  *
2030
+ * SUBTLETY: for a slot in a fully DETACHED chain the walk ends at null
2031
+ * without reaching `host` and returns vacuously true. Callers that must not
2032
+ * treat a FOREIGN detached slot as owned (the prune rule, isVirtualChild)
2033
+ * pair this with a `host.contains(p)` gate; adopted-SSR children in the
2034
+ * detached old chain survive pruning via their FRAMEWORK_DETACHED mark, not
2035
+ * via this walk.
2036
+ *
530
2037
  * @param {Element} host
531
2038
  * @param {Element} slot
532
2039
  * @returns {boolean}
533
2040
  */
534
2041
  function isOwnSlot(host, slot) {
2042
+ // Template-ownership is authoritative when known: a forwarded slot sits
2043
+ // physically inside a child component but belongs to the host whose
2044
+ // template rendered it. Two carriers of the SAME fact, consulted in order:
2045
+ // 1. the SLOT_OWNER symbol, stamped by the client renderer at render
2046
+ // time (createInstance) and thus present once this host has rendered;
2047
+ // 2. the data-wj-slot-owner attribute, the SSR carrier, which is the
2048
+ // ACTIVE resolver on the adopt/hydration path (adoptSSRAssignments
2049
+ // runs in connectedCallback, before the deferred first render stamps
2050
+ // the symbol), resolved by nearest-matching-tag ancestor.
2051
+ const owner = /** @type {any} */ (slot)[SLOT_OWNER];
2052
+ if (owner) return owner === host;
2053
+ const ownerTag =
2054
+ typeof slot.getAttribute === 'function' ? slot.getAttribute(SLOT_OWNER_ATTR) : null;
2055
+ if (ownerTag) {
2056
+ const resolved = ownerHostFor(slot, ownerTag);
2057
+ if (resolved) return resolved === host;
2058
+ // Unresolvable (a detached chain the owner is no longer an ancestor of):
2059
+ // fall through to the structural walk rather than falsely denying.
2060
+ }
2061
+ // Structural fallback: no OTHER custom element sits between slot and host.
535
2062
  for (let p = slot.parentElement; p && p !== host; p = p.parentElement) {
536
2063
  if (p.tagName.includes('-')) return false;
537
2064
  }
538
2065
  return true;
539
2066
  }
540
2067
 
2068
+ /**
2069
+ * The host a `data-wj-slot-owner="<tag>"` attribute resolves to: the nearest
2070
+ * ANCESTOR whose tag matches, by tag alone (NOT gated on SLOT_STATE). The
2071
+ * gate would be wrong during the connect-time chooser, where an inner host
2072
+ * has not upgraded yet, so a forwarded slot would fail to resolve to its
2073
+ * outer owner and the outer would wrongly capture-hoover the SSR subtree.
2074
+ * One-level forwarding resolves cleanly; same-tag-nested forwarding picks
2075
+ * the nearest (the accepted edge, no worse than the structural walk it
2076
+ * replaces).
2077
+ *
2078
+ * @param {Element} slot
2079
+ * @param {string} ownerTag
2080
+ * @returns {Element | null}
2081
+ */
2082
+ function ownerHostFor(slot, ownerTag) {
2083
+ const want = ownerTag.toLowerCase();
2084
+ for (let p = slot.parentElement; p; p = p.parentElement) {
2085
+ if (p.tagName.toLowerCase() === want) return p;
2086
+ }
2087
+ return null;
2088
+ }
2089
+
541
2090
  /**
542
2091
  * Set a slot to actual-assignment mode and move the given nodes into it.
543
2092
  * Preserves DOM identity by re-using the same Node references when they
@@ -550,20 +2099,25 @@ function isOwnSlot(host, slot) {
550
2099
  * its last snapshot (so slotchange should fire).
551
2100
  */
552
2101
  export function applyActualAssignment(state, slot, assigned) {
2102
+ // These nodes are being placed into an own actual slot, so they are
2103
+ // author-live now: clear the prune exemption on EVERY path (including the
2104
+ // unchanged and in-place fast paths below, which the router's morph hits).
2105
+ // Missing this leaves a reprojected node permanently exempt, so a later
2106
+ // el.remove() / cross-host move on it would not be pruned (zombie / theft).
2107
+ for (const n of assigned) FRAMEWORK_DETACHED.delete(n);
553
2108
  const wasFallback = slot.getAttribute(PROJECTION_ATTR) !== PROJECTION_ACTUAL;
554
2109
  const prev = state.lastSnapshot.get(slot) || [];
555
- const equal = !wasFallback && arraysEqual(prev, assigned);
556
- if (equal) return false;
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.
2110
+ const setChanged = wasFallback || !arraysEqual(prev, assigned);
2111
+
2112
+ // Physical fast path: the assigned nodes are ALREADY the slot's children in
2113
+ // the same order (the idempotent no-change pass, and the router's morph
2114
+ // which reconciles in place then syncs the record). Settle the snapshot;
2115
+ // slotchange reflects the SET change vs the previous snapshot. Verifying
2116
+ // physically (never trusting the snapshot alone) also makes the apply
2117
+ // self-repairing after a bypass move pulled a node out of the slot.
564
2118
  if (!wasFallback && arraysEqual(Array.from(slot.childNodes), assigned)) {
565
2119
  state.lastSnapshot.set(slot, assigned.slice());
566
- return !arraysEqual(prev, assigned);
2120
+ return setChanged;
567
2121
  }
568
2122
 
569
2123
  // Preserve fallback content. If the slot currently holds fallback nodes
@@ -577,19 +2131,36 @@ export function applyActualAssignment(state, slot, assigned) {
577
2131
  );
578
2132
  if (wasFallback && fallbackFrag) {
579
2133
  while (slot.firstChild) fallbackFrag.appendChild(slot.firstChild);
580
- } else {
581
- while (slot.firstChild) slot.removeChild(slot.firstChild);
582
2134
  }
583
- for (const node of assigned) slot.appendChild(node);
2135
+
2136
+ // Incremental reconcile, native parity: an assignment change must never
2137
+ // reparent a SURVIVING assigned node (native shadow assignment does not
2138
+ // move host children), or appending one sibling would bounce every nested
2139
+ // custom element through disconnect/connect, drop focus, and reload an
2140
+ // <iframe>/<video> in the projected content. Remove departures, then
2141
+ // position each assigned node touching ONLY the new, departing, or
2142
+ // out-of-order ones.
2143
+ const want = new Set(assigned);
2144
+ for (const c of Array.from(slot.childNodes)) {
2145
+ if (!want.has(c)) slot.removeChild(c);
2146
+ }
2147
+ let cursor = slot.firstChild;
2148
+ for (const node of assigned) {
2149
+ if (node === cursor) {
2150
+ cursor = cursor.nextSibling;
2151
+ continue;
2152
+ }
2153
+ slot.insertBefore(node, cursor);
2154
+ }
584
2155
  slot.setAttribute(PROJECTION_ATTR, PROJECTION_ACTUAL);
585
2156
  state.lastSnapshot.set(slot, assigned.slice());
586
- return true;
2157
+ return setChanged;
587
2158
  }
588
2159
 
589
2160
  /**
590
2161
  * Set a slot to fallback mode: clear any actual-assignment children and
591
2162
  * restore the part-owned fallback fragment. The record keeps the nodes
592
- * (they are values now), so a later `setSlotContent` or slot re-creation
2163
+ * (they are values now), so a later authored write or slot re-creation
593
2164
  * re-places them.
594
2165
  *
595
2166
  * @param {SlotState} state
@@ -650,7 +2221,13 @@ export function rescueAssignedNodes(host, slot) {
650
2221
  const assigned = state.assignedByName.get(name);
651
2222
  if (assigned) {
652
2223
  for (const node of assigned) {
653
- if (node.parentNode === slot) slot.removeChild(node);
2224
+ if (node.parentNode === slot) {
2225
+ // Framework-detached on teardown: the record keeps the ref (children
2226
+ // are values), so the prune rule must not drop it while it is parked
2227
+ // out of the tree waiting for a re-created slot to re-place it.
2228
+ FRAMEWORK_DETACHED.add(node);
2229
+ slot.removeChild(node);
2230
+ }
654
2231
  }
655
2232
  }
656
2233
  state.lastSnapshot.delete(slot);
@@ -665,6 +2242,31 @@ export function fireSlotChange(slot) {
665
2242
  slot.dispatchEvent(new Event('slotchange', { bubbles: true, composed: false }));
666
2243
  }
667
2244
 
2245
+ /**
2246
+ * Queue a coalesced `slotchange` for a slot. The event is dispatched on a
2247
+ * microtask, and a slot that changes more than once before the flush fires
2248
+ * exactly once (native `slotchange` timing: async and coalesced per slot).
2249
+ * A slot detached before the flush is skipped.
2250
+ *
2251
+ * @param {SlotState} state
2252
+ * @param {HTMLSlotElement} slot
2253
+ */
2254
+ export function queueSlotChange(state, slot) {
2255
+ if (!state.pendingSlotChanges) state.pendingSlotChanges = new Set();
2256
+ state.pendingSlotChanges.add(slot);
2257
+ if (state.slotChangeScheduled) return;
2258
+ state.slotChangeScheduled = true;
2259
+ queueMicrotask(() => {
2260
+ state.slotChangeScheduled = false;
2261
+ const pending = state.pendingSlotChanges || new Set();
2262
+ state.pendingSlotChanges = new Set();
2263
+ // Fire regardless of connectivity: native slot assignment (and its
2264
+ // slotchange) works in disconnected trees, and dropping the event here
2265
+ // would lose it forever for a detached-then-reused host.
2266
+ for (const s of pending) fireSlotChange(s);
2267
+ });
2268
+ }
2269
+
668
2270
  // ---------------------------------------------------------------------------
669
2271
  // Misc helpers
670
2272
  // ---------------------------------------------------------------------------