@webjsdev/core 0.7.1

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 ADDED
@@ -0,0 +1,860 @@
1
+ /**
2
+ * Light-DOM <slot> runtime for @webjsdev/core.
3
+ *
4
+ * Provides functional parity with shadow-DOM <slot> projection inside
5
+ * light-DOM WebComponents (those with static shadow = false). The framework
6
+ * physically projects authored children into <slot> elements at render
7
+ * time and fires slotchange events. The DOM API surface (assignedNodes,
8
+ * assignedElements, assignedSlot) is polyfilled on HTMLSlotElement and
9
+ * Element prototypes so user code reads the same against light-DOM slots
10
+ * as it does against native shadow-DOM slots.
11
+ *
12
+ * Polyfill safety. Every prototype patch checks for the `data-webjs-light`
13
+ * attribute on the slot element and falls through to the saved native
14
+ * implementation otherwise. Real shadow-DOM slots elsewhere on the page
15
+ * keep their native behaviour exactly.
16
+ *
17
+ * SSR. This module is import-safe in Node. The polyfill setup is guarded
18
+ * on `typeof HTMLSlotElement !== 'undefined'`, so the server pipeline
19
+ * loads slot.js without blowing up. Server-side slot substitution lives
20
+ * in render-server.js (injectDSD); slot.js drives the client runtime
21
+ * only.
22
+ *
23
+ * Cross-file coordination. Two pieces of behaviour live partly here and
24
+ * partly in render-client.js:
25
+ * 1. Fallback content restoration. When a slot transitions to
26
+ * data-projection="fallback", slot.js clears the actual-assigned
27
+ * children; the slot-part's apply step in render-client.js
28
+ * restores the compiled fallback template into the slot.
29
+ * 2. Slot-part teardown (slot inside a conditional that collapsed).
30
+ * render-client.js calls movePendingFromTorndownSlot() before
31
+ * removing the slot from the DOM so its assigned children survive
32
+ * to be re-projected on the next render.
33
+ */
34
+
35
+ import { MARKER } from './html.js';
36
+
37
+ // ---------------------------------------------------------------------------
38
+ // Module-scope constants
39
+ // ---------------------------------------------------------------------------
40
+
41
+ // Comment-node values used by render-client.js to bookend every
42
+ // rendered template instance. Used to distinguish framework-driven
43
+ // childList mutations from user-driven appendChild / removeChild in
44
+ // the host's MutationObserver below.
45
+ const FRAMEWORK_MARKER_START = `${MARKER}s`;
46
+ const FRAMEWORK_MARKER_END = `${MARKER}e`;
47
+
48
+ /**
49
+ * True when the node is one of the framework's render-instance bookend
50
+ * comment markers. The render-client.js paths that insert into or
51
+ * remove from a slot host always include such a marker in the same
52
+ * MutationRecord (either by `replaceChildren(start, ...content, end)`
53
+ * at the top level or by `insertBefore(frag, marker)` where `frag`
54
+ * itself is built from a `nodesToFrag([start, ...content, end])`).
55
+ * Mutation records that touch one of these markers therefore belong
56
+ * to the framework's render commit, not user authoring.
57
+ *
58
+ * @param {Node} node
59
+ * @returns {boolean}
60
+ */
61
+ function isFrameworkMarker(node) {
62
+ if (!node || node.nodeType !== 8 /* COMMENT_NODE */) return false;
63
+ const v = node.nodeValue;
64
+ return v === FRAMEWORK_MARKER_START || v === FRAMEWORK_MARKER_END;
65
+ }
66
+
67
+ /**
68
+ * True when a MutationRecord's added or removed nodes include a
69
+ * framework bookend marker. Used by the host's childList observer
70
+ * to drop renderer-driven records that would otherwise be
71
+ * misinterpreted as authored-child changes.
72
+ *
73
+ * @param {MutationRecord} record
74
+ * @returns {boolean}
75
+ */
76
+ function isFrameworkRecord(record) {
77
+ for (const n of record.addedNodes) if (isFrameworkMarker(n)) return true;
78
+ for (const n of record.removedNodes) if (isFrameworkMarker(n)) return true;
79
+ return false;
80
+ }
81
+
82
+ function detectBrowser() {
83
+ return typeof HTMLElement !== 'undefined' && typeof HTMLSlotElement !== 'undefined';
84
+ }
85
+
86
+ let inBrowser = detectBrowser();
87
+
88
+ /**
89
+ * Symbol-keyed slot state stored on each light-DOM WebComponent host.
90
+ * Lazily initialised by ensureSlotState(host).
91
+ */
92
+ export const SLOT_STATE = Symbol('webjs.slot.state');
93
+
94
+ /** Marker attribute that opts a <slot> element into framework projection. */
95
+ export const LIGHT_SLOT_ATTR = 'data-webjs-light';
96
+
97
+ /** Records whether a slot is showing real assignment or fallback. */
98
+ export const PROJECTION_ATTR = 'data-projection';
99
+
100
+ export const PROJECTION_ACTUAL = 'actual';
101
+ export const PROJECTION_FALLBACK = 'fallback';
102
+
103
+ /**
104
+ * Symbol-keyed property on a slot element that holds a DocumentFragment
105
+ * containing the slot's fallback content (cloned from the compiled template
106
+ * by render-client.js at slot-part bind time). slot.js swaps these nodes
107
+ * into and out of the slot as the projection state toggles between
108
+ * "actual" and "fallback".
109
+ */
110
+ export const SLOT_FALLBACK_FRAG = Symbol('webjs.slot.fallbackFrag');
111
+
112
+ /** Maximum recursion depth for assignedNodes({flatten: true}); guards cycles. */
113
+ const FLATTEN_MAX_DEPTH = 64;
114
+
115
+ // ---------------------------------------------------------------------------
116
+ // Saved native references and prototype polyfills
117
+ //
118
+ // Module-load tries to install polyfills immediately. In a pure Node
119
+ // process without a DOM library, HTMLSlotElement is undefined and the
120
+ // install is a no-op. Tests that set up linkedom AFTER module load can
121
+ // call installSlotPolyfills() explicitly to re-attempt the install.
122
+ // Subsequent calls are idempotent; native references are captured only
123
+ // on the first successful install.
124
+ // ---------------------------------------------------------------------------
125
+
126
+ let NATIVE_assignedNodes = null;
127
+ let NATIVE_assignedElements = null;
128
+ let NATIVE_assignedSlot_desc = null;
129
+ let polyfillsInstalled = false;
130
+
131
+ /**
132
+ * Install the slot DOM-API polyfills on HTMLSlotElement.prototype and
133
+ * Element.prototype if the current realm has those globals. Idempotent.
134
+ * No-op when the realm has no DOM (server-side import-only path).
135
+ */
136
+ export function installSlotPolyfills() {
137
+ if (polyfillsInstalled) return;
138
+ inBrowser = detectBrowser();
139
+ if (!inBrowser) return;
140
+ NATIVE_assignedNodes = HTMLSlotElement.prototype.assignedNodes;
141
+ NATIVE_assignedElements = HTMLSlotElement.prototype.assignedElements;
142
+ NATIVE_assignedSlot_desc = Object.getOwnPropertyDescriptor(Element.prototype, 'assignedSlot');
143
+
144
+ HTMLSlotElement.prototype.assignedNodes = function patchedAssignedNodes(options) {
145
+ // Two conditions must both hold for the polyfill to take over:
146
+ // 1. The slot carries the framework's data-webjs-light marker.
147
+ // 2. The slot is NOT currently inside a shadow root.
148
+ // Slots that end up in a shadow tree (their host has a ShadowRoot)
149
+ // delegate to native projection, which the browser performs from the
150
+ // host's light-DOM children. discoverSlots cannot tell at template
151
+ // compile time whether the template will be cloned into a light or
152
+ // shadow render root (templates cache by strings identity), so the
153
+ // shadow-vs-light determination has to happen on every API call.
154
+ if (this.hasAttribute(LIGHT_SLOT_ATTR) && !isInShadowRoot(this)) {
155
+ return lightAssignedNodes(this, options);
156
+ }
157
+ return NATIVE_assignedNodes ? NATIVE_assignedNodes.call(this, options) : [];
158
+ };
159
+
160
+ HTMLSlotElement.prototype.assignedElements = function patchedAssignedElements(options) {
161
+ if (this.hasAttribute(LIGHT_SLOT_ATTR) && !isInShadowRoot(this)) {
162
+ return lightAssignedNodes(this, options).filter((n) => n.nodeType === 1);
163
+ }
164
+ return NATIVE_assignedElements ? NATIVE_assignedElements.call(this, options) : [];
165
+ };
166
+
167
+ Object.defineProperty(Element.prototype, 'assignedSlot', {
168
+ configurable: true,
169
+ enumerable: true,
170
+ get: function patchedAssignedSlot() {
171
+ const native =
172
+ NATIVE_assignedSlot_desc && NATIVE_assignedSlot_desc.get
173
+ ? NATIVE_assignedSlot_desc.get.call(this)
174
+ : null;
175
+ if (native) return native;
176
+ return findLightAssignedSlot(this);
177
+ },
178
+ });
179
+ polyfillsInstalled = true;
180
+ }
181
+
182
+ // First-chance install at module load.
183
+ installSlotPolyfills();
184
+
185
+ /**
186
+ * True when the given node lives inside a shadow root (so native browser
187
+ * slot projection applies). A ShadowRoot exposes its owning element as
188
+ * `host`; the document does not. Walks the parentNode chain manually
189
+ * with a depth cap to avoid hangs on accidentally cyclic DOMs (e.g.,
190
+ * test fixtures that wire two slots into each other).
191
+ *
192
+ * @param {Node} node
193
+ * @returns {boolean}
194
+ */
195
+ function isInShadowRoot(node) {
196
+ let n = node;
197
+ for (let depth = 0; depth < 128; depth++) {
198
+ const parent = n.parentNode;
199
+ if (!parent) return false;
200
+ if (parent === n) return false;
201
+ if (/** @type {any} */ (parent).host) return true;
202
+ n = parent;
203
+ }
204
+ return false;
205
+ }
206
+
207
+ /**
208
+ * Resolve assigned nodes for a light-DOM slot. Per spec, returns []
209
+ * when the slot is displaying fallback content.
210
+ *
211
+ * @param {HTMLSlotElement} slot
212
+ * @param {{ flatten?: boolean }} [options]
213
+ * @returns {Node[]}
214
+ */
215
+ function lightAssignedNodes(slot, options) {
216
+ if (slot.getAttribute(PROJECTION_ATTR) === PROJECTION_FALLBACK) return [];
217
+ const direct = Array.from(slot.childNodes);
218
+ if (!options || !options.flatten) return direct;
219
+ return flattenAssignedNodes(direct, new Set(), 0);
220
+ }
221
+
222
+ /**
223
+ * Walk a node list, expanding any data-webjs-light slot into its assigned
224
+ * nodes recursively. Native shadow slots encountered in the chain delegate
225
+ * to their native assignedNodes({flatten: true}).
226
+ *
227
+ * @param {Node[]} nodes
228
+ * @param {Set<HTMLSlotElement>} visited
229
+ * @param {number} depth
230
+ * @returns {Node[]}
231
+ */
232
+ function flattenAssignedNodes(nodes, visited, depth) {
233
+ if (depth >= FLATTEN_MAX_DEPTH) return nodes.slice();
234
+ const out = [];
235
+ for (const node of nodes) {
236
+ if (node.nodeType === 1 && /** @type {Element} */ (node).tagName === 'SLOT') {
237
+ const slot = /** @type {HTMLSlotElement} */ (node);
238
+ if (visited.has(slot)) continue;
239
+ visited.add(slot);
240
+ if (slot.hasAttribute(LIGHT_SLOT_ATTR)) {
241
+ const inner = lightAssignedNodes(slot, { flatten: false });
242
+ if (inner.length > 0) {
243
+ for (const n of flattenAssignedNodes(inner, visited, depth + 1)) out.push(n);
244
+ } else {
245
+ // Fallback content contributes its children.
246
+ for (const n of flattenAssignedNodes(Array.from(slot.childNodes), visited, depth + 1)) {
247
+ out.push(n);
248
+ }
249
+ }
250
+ } else if (NATIVE_assignedNodes) {
251
+ const inner = NATIVE_assignedNodes.call(slot, { flatten: true });
252
+ for (const n of inner) out.push(n);
253
+ } else {
254
+ out.push(node);
255
+ }
256
+ } else {
257
+ out.push(node);
258
+ }
259
+ }
260
+ return out;
261
+ }
262
+
263
+ /**
264
+ * Walk an element's ancestor chain to find a data-webjs-light slot it is
265
+ * currently projected into. Returns null if the element is in a fallback
266
+ * slot or no light slot at all.
267
+ *
268
+ * @param {Element} el
269
+ * @returns {HTMLSlotElement | null}
270
+ */
271
+ function findLightAssignedSlot(el) {
272
+ let p = el.parentElement;
273
+ while (p) {
274
+ if (p.tagName === 'SLOT' && p.hasAttribute(LIGHT_SLOT_ATTR)) {
275
+ return p.getAttribute(PROJECTION_ATTR) === PROJECTION_ACTUAL
276
+ ? /** @type {HTMLSlotElement} */ (p)
277
+ : null;
278
+ }
279
+ p = p.parentElement;
280
+ }
281
+ return null;
282
+ }
283
+
284
+ // ---------------------------------------------------------------------------
285
+ // Per-host state
286
+ // ---------------------------------------------------------------------------
287
+
288
+ /**
289
+ * @typedef {Object} SlotState
290
+ * @property {Map<string|null, Node[]>} assignedByName Captured authored
291
+ * children per slot name (null is the default slot).
292
+ * @property {Map<string|null, Node[]>} pendingByName Children awaiting a
293
+ * slot to be (re-)created. Populated when a slot is torn down by a
294
+ * conditional collapse or removed from the render tree.
295
+ * @property {WeakMap<HTMLSlotElement, Node[]>} lastSnapshot Per-slot
296
+ * record of the previous assigned-node set for slotchange equality.
297
+ * @property {WeakMap<HTMLSlotElement, MutationObserver>} nameObservers
298
+ * Per-slot `name` attribute observers.
299
+ * @property {MutationObserver | null} childObserver Host's childList +
300
+ * child `slot` attribute observer.
301
+ * @property {boolean} scheduled Microtask flag for batched projection.
302
+ * @property {Set<HTMLSlotElement>} ownedSlots Slots we currently track.
303
+ */
304
+
305
+ /**
306
+ * Lazily create and return the slot state for a host element.
307
+ *
308
+ * @param {Element} host
309
+ * @returns {SlotState}
310
+ */
311
+ export function ensureSlotState(host) {
312
+ /** @type {any} */
313
+ const h = host;
314
+ let state = h[SLOT_STATE];
315
+ if (!state) {
316
+ state = {
317
+ assignedByName: new Map(),
318
+ pendingByName: new Map(),
319
+ lastSnapshot: new WeakMap(),
320
+ nameObservers: new WeakMap(),
321
+ childObserver: null,
322
+ scheduled: false,
323
+ ownedSlots: new Set(),
324
+ };
325
+ h[SLOT_STATE] = state;
326
+ }
327
+ return state;
328
+ }
329
+
330
+ /** True when the host has slot state initialised. */
331
+ export function hasSlotState(host) {
332
+ return Boolean(/** @type {any} */ (host)[SLOT_STATE]);
333
+ }
334
+
335
+ // ---------------------------------------------------------------------------
336
+ // Authored-child capture
337
+ // ---------------------------------------------------------------------------
338
+
339
+ /**
340
+ * Move every authored child of `host` into the slot state, partitioning
341
+ * by each child's `slot=""` attribute. After this runs, `host` has no
342
+ * children; the framework re-inserts them at projection time inside the
343
+ * correct <slot> elements. Idempotent.
344
+ *
345
+ * @param {Element} host
346
+ */
347
+ export function captureAuthoredChildren(host) {
348
+ const state = ensureSlotState(host);
349
+ while (host.firstChild) {
350
+ const node = host.firstChild;
351
+ const name = slotNameOf(node);
352
+ appendToMap(state.assignedByName, name, node);
353
+ host.removeChild(node);
354
+ }
355
+ }
356
+
357
+ /**
358
+ * After SSR + hydration, projected children already live inside their
359
+ * <slot data-webjs-light> elements. Walk the host's render tree and
360
+ * record those existing assignments in the state without moving DOM.
361
+ *
362
+ * @param {Element} host
363
+ */
364
+ export function adoptSSRAssignments(host) {
365
+ const state = ensureSlotState(host);
366
+ const slots = host.querySelectorAll(`slot[${LIGHT_SLOT_ATTR}]`);
367
+ for (const slot of slots) {
368
+ /** @type {HTMLSlotElement} */
369
+ const s = /** @type {any} */ (slot);
370
+ if (s.getAttribute(PROJECTION_ATTR) !== PROJECTION_ACTUAL) continue;
371
+ const name = s.getAttribute('name') || null;
372
+ if (!state.assignedByName.has(name)) {
373
+ const children = Array.from(s.childNodes);
374
+ state.assignedByName.set(name, children);
375
+ state.lastSnapshot.set(s, children.slice());
376
+ }
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Read the slot="" attribute on an element child. Text and comment nodes
382
+ * always route to the default slot (key = null).
383
+ *
384
+ * @param {Node} node
385
+ * @returns {string | null}
386
+ */
387
+ function slotNameOf(node) {
388
+ if (node.nodeType !== 1) return null;
389
+ const el = /** @type {Element} */ (node);
390
+ const v = el.getAttribute('slot');
391
+ return v ? v : null;
392
+ }
393
+
394
+ /** Append a value to a Map<K, V[]>, creating the array on first hit. */
395
+ function appendToMap(map, key, value) {
396
+ let arr = map.get(key);
397
+ if (!arr) {
398
+ arr = [];
399
+ map.set(key, arr);
400
+ }
401
+ arr.push(value);
402
+ }
403
+
404
+ /** Append a list of values to a Map<K, V[]>. */
405
+ function appendArrayToMap(map, key, values) {
406
+ if (!values.length) return;
407
+ let arr = map.get(key);
408
+ if (!arr) {
409
+ arr = [];
410
+ map.set(key, arr);
411
+ }
412
+ for (const v of values) arr.push(v);
413
+ }
414
+
415
+ // ---------------------------------------------------------------------------
416
+ // Observers
417
+ // ---------------------------------------------------------------------------
418
+
419
+ /**
420
+ * Attach mutation observers to a host. Watches:
421
+ * 1. Host's childList for authored children added or removed at runtime.
422
+ * 2. Host's children's `slot` attribute for children moving between slots.
423
+ *
424
+ * Slot-name observers are attached separately by projectChildren when it
425
+ * discovers a new slot element.
426
+ *
427
+ * @param {Element} host
428
+ */
429
+ export function attachSlotObservers(host) {
430
+ if (!inBrowser) return;
431
+ const state = ensureSlotState(host);
432
+ if (state.childObserver) return;
433
+ state.childObserver = new MutationObserver((records) => {
434
+ let dirty = false;
435
+ for (const r of records) {
436
+ // Skip records produced by the framework's own renderer. Each
437
+ // top-level TemplateInstance commit (`createInstance`) and each
438
+ // child-part template swap (`applyChildPart`) inserts and removes
439
+ // its content as a unit bounded by `w$s`/`w$e` comment markers
440
+ // (see render-client.js). When such a record reaches the host's
441
+ // childList observer, treating its addedNodes as authored
442
+ // children pollutes assignedByName with the renderer's own
443
+ // wrapper elements; projectChildren then tries to append those
444
+ // wrappers into a <slot> they contain, raising a
445
+ // HierarchyRequestError ("new child element contains the
446
+ // parent"). Filtering on marker presence isolates renderer
447
+ // commits from real authoring (which never inserts framework
448
+ // markers).
449
+ if (isFrameworkRecord(r)) continue;
450
+ if (r.type === 'childList') {
451
+ for (const node of r.addedNodes) {
452
+ // A new child appeared directly under host (not via slot.append
453
+ // inside our own projection routine, which appends deep inside
454
+ // the rendered template and isn't visible with subtree:false).
455
+ // Record it in the assignment table and let projection move it.
456
+ if (node.parentElement === host) {
457
+ appendToMap(state.assignedByName, slotNameOf(node), node);
458
+ dirty = true;
459
+ }
460
+ }
461
+ for (const node of r.removedNodes) {
462
+ // Skip when the node is still inside the host's subtree. That
463
+ // means projection moved it from host's direct children into
464
+ // a slot deeper down; the assignment table should keep the
465
+ // entry. Only treat as a user-removal when the node truly left.
466
+ if (host.contains(node)) continue;
467
+ if (removeFromAssignments(state, node)) dirty = true;
468
+ }
469
+ } else if (r.type === 'attributes' && r.attributeName === 'slot') {
470
+ const target = /** @type {Element} */ (r.target);
471
+ // A child's slot=" " attribute changed. If it's an authored
472
+ // child currently in the assignment table (either still a
473
+ // direct child of host or already projected into a slot deeper
474
+ // down), re-partition by the new name.
475
+ if (removeFromAssignments(state, target)) {
476
+ appendToMap(state.assignedByName, slotNameOf(target), target);
477
+ dirty = true;
478
+ } else if (target.parentElement === host) {
479
+ // Edge case where state.removeFromAssignments missed it: still
480
+ // direct on host and not yet captured.
481
+ appendToMap(state.assignedByName, slotNameOf(target), target);
482
+ dirty = true;
483
+ }
484
+ }
485
+ }
486
+ if (dirty) scheduleProjection(host);
487
+ });
488
+ // subtree: true so children moved into <slot> deeper in the tree are
489
+ // still observed when their slot=" " attribute changes (which would
490
+ // otherwise be invisible under subtree: false). The attribute filter
491
+ // keeps the observed surface small. childList stays scoped to host's
492
+ // direct children: appending a node to host is the user-level entry
493
+ // point for authoring; projection's slot.appendChild is on a node
494
+ // deeper in the tree and the filter does not fire on those.
495
+ state.childObserver.observe(host, {
496
+ childList: true,
497
+ attributes: true,
498
+ attributeFilter: ['slot'],
499
+ subtree: true,
500
+ });
501
+ }
502
+
503
+ /**
504
+ * Detach the host's child observer and every slot-name observer. Called
505
+ * from disconnectedCallback. The state itself is preserved so that a
506
+ * subsequent reconnection picks up where it left off.
507
+ *
508
+ * @param {Element} host
509
+ */
510
+ export function detachSlotObservers(host) {
511
+ /** @type {SlotState | undefined} */
512
+ const state = /** @type {any} */ (host)[SLOT_STATE];
513
+ if (!state) return;
514
+ if (state.childObserver) {
515
+ state.childObserver.disconnect();
516
+ state.childObserver = null;
517
+ }
518
+ for (const slot of state.ownedSlots) {
519
+ const obs = state.nameObservers.get(slot);
520
+ if (obs) {
521
+ obs.disconnect();
522
+ state.nameObservers.delete(slot);
523
+ }
524
+ }
525
+ }
526
+
527
+ /** Remove a node from the assignedByName map. Returns true if removed. */
528
+ function removeFromAssignments(state, node) {
529
+ for (const [key, arr] of state.assignedByName) {
530
+ const idx = arr.indexOf(node);
531
+ if (idx !== -1) {
532
+ arr.splice(idx, 1);
533
+ if (arr.length === 0) state.assignedByName.delete(key);
534
+ return true;
535
+ }
536
+ }
537
+ return false;
538
+ }
539
+
540
+ /**
541
+ * True when `node` is currently slotted inside one of the host's owned
542
+ * slots. Used to decide whether a `slot` attribute mutation on an
543
+ * already-projected node should trigger a re-projection.
544
+ */
545
+ function hostOwnsAssignedNode(host, node) {
546
+ let p = node.parentElement;
547
+ while (p && p !== host) {
548
+ if (p.tagName === 'SLOT' && p.hasAttribute(LIGHT_SLOT_ATTR)) {
549
+ let q = p.parentElement;
550
+ while (q && q !== host) q = q.parentElement;
551
+ return q === host;
552
+ }
553
+ p = p.parentElement;
554
+ }
555
+ return false;
556
+ }
557
+
558
+ // ---------------------------------------------------------------------------
559
+ // Microtask-batched projection scheduler
560
+ // ---------------------------------------------------------------------------
561
+
562
+ /**
563
+ * Schedule a projection pass for the host on the next microtask. Multiple
564
+ * calls within one synchronous task collapse to one projection. This is
565
+ * the entry point every observer callback uses.
566
+ *
567
+ * @param {Element} host
568
+ */
569
+ export function scheduleProjection(host) {
570
+ const state = ensureSlotState(host);
571
+ if (state.scheduled) return;
572
+ state.scheduled = true;
573
+ queueMicrotask(() => {
574
+ state.scheduled = false;
575
+ projectChildren(host);
576
+ });
577
+ }
578
+
579
+ // ---------------------------------------------------------------------------
580
+ // Core projection routine
581
+ // ---------------------------------------------------------------------------
582
+
583
+ /**
584
+ * Walk the host's render tree for light-DOM slots, group by their current
585
+ * `name` attribute, apply the first-wins assignment rule, then materialise
586
+ * the result in DOM. Idempotent and cheap on no-change passes.
587
+ *
588
+ * 1. Each slot is marked data-projection="actual" or "fallback".
589
+ * 2. Each "actual" slot has its current children replaced by the
590
+ * assigned nodes (Node identity preserved for already-projected
591
+ * refs by reusing the same Node objects).
592
+ * 3. Each "fallback" slot is reset by the framework's slot-part apply
593
+ * step in render-client.js, which restores the compiled fallback
594
+ * template.
595
+ * 4. slotchange fires on slots whose assigned-node set changed.
596
+ *
597
+ * @param {Element} host
598
+ */
599
+ export function projectChildren(host) {
600
+ if (!inBrowser) return;
601
+ const state = /** @type {SlotState | undefined} */ (
602
+ /** @type {any} */ (host)[SLOT_STATE]
603
+ );
604
+ if (!state) return;
605
+
606
+ // 1. Collect every owned slot in document order.
607
+ const slots = /** @type {HTMLSlotElement[]} */ (
608
+ Array.from(host.querySelectorAll(`slot[${LIGHT_SLOT_ATTR}]`))
609
+ );
610
+
611
+ // 2. Reconcile ownedSlots membership. Slots that disappeared from the
612
+ // render tree have their assigned children moved to pending so a future
613
+ // re-render can re-project them (slot inside a conditional that
614
+ // collapsed, etc.).
615
+ const newOwned = new Set(slots);
616
+ for (const slot of state.ownedSlots) {
617
+ if (!newOwned.has(slot)) {
618
+ handleSlotRemoved(state, slot);
619
+ }
620
+ }
621
+ for (const slot of newOwned) {
622
+ if (!state.ownedSlots.has(slot)) {
623
+ attachNameObserver(host, slot);
624
+ }
625
+ }
626
+ state.ownedSlots = newOwned;
627
+
628
+ // 3. Group slots by current `name` attribute in document order.
629
+ /** @type {Map<string|null, HTMLSlotElement[]>} */
630
+ const groups = new Map();
631
+ for (const slot of slots) {
632
+ const name = slot.getAttribute('name') || null;
633
+ let arr = groups.get(name);
634
+ if (!arr) {
635
+ arr = [];
636
+ groups.set(name, arr);
637
+ }
638
+ arr.push(slot);
639
+ }
640
+
641
+ // 4. Drain pending children into assignedByName per group. Pending
642
+ // represents previously-displayed projection (a slot disappeared and
643
+ // is reappearing); putting it ahead of newer captured children keeps
644
+ // visual order stable across conditional toggles.
645
+ for (const name of groups.keys()) {
646
+ const pending = state.pendingByName.get(name);
647
+ if (pending && pending.length) {
648
+ const current = state.assignedByName.get(name) || [];
649
+ const merged = [];
650
+ for (const n of pending) merged.push(n);
651
+ for (const n of current) if (merged.indexOf(n) === -1) merged.push(n);
652
+ state.assignedByName.set(name, merged);
653
+ state.pendingByName.delete(name);
654
+ }
655
+ }
656
+
657
+ // 5. Assign per the first-wins rule. Primary (index 0) of each group
658
+ // gets the actual children; the rest show fallback.
659
+ /** @type {HTMLSlotElement[]} */
660
+ const slotsChanged = [];
661
+ for (const [name, group] of groups) {
662
+ const assigned = state.assignedByName.get(name) || [];
663
+ for (let i = 0; i < group.length; i++) {
664
+ const slot = group[i];
665
+ if (i === 0 && assigned.length > 0) {
666
+ if (applyActualAssignment(state, slot, assigned)) {
667
+ slotsChanged.push(slot);
668
+ }
669
+ } else {
670
+ if (applyFallback(state, slot)) slotsChanged.push(slot);
671
+ }
672
+ }
673
+ }
674
+
675
+ // 6. Dispatch slotchange on slots whose assignment actually changed.
676
+ for (const slot of slotsChanged) fireSlotChange(slot);
677
+ }
678
+
679
+ /**
680
+ * Move a slot's previous assignment to the pending map keyed by the
681
+ * slot's last-known name. Used when the slot itself disappears from the
682
+ * render tree (e.g., conditional collapse).
683
+ *
684
+ * @param {SlotState} state
685
+ * @param {HTMLSlotElement} slot
686
+ */
687
+ function handleSlotRemoved(state, slot) {
688
+ const obs = state.nameObservers.get(slot);
689
+ if (obs) {
690
+ obs.disconnect();
691
+ state.nameObservers.delete(slot);
692
+ }
693
+ const prev = state.lastSnapshot.get(slot);
694
+ if (prev && prev.length > 0) {
695
+ const lastName = slot.getAttribute('name') || null;
696
+ appendArrayToMap(state.pendingByName, lastName, prev);
697
+ }
698
+ state.lastSnapshot.delete(slot);
699
+ }
700
+
701
+ /**
702
+ * Set a slot to actual-assignment mode and move the given nodes into it.
703
+ * Preserves DOM identity by re-using the same Node references when they
704
+ * are already inside the slot in the same order.
705
+ *
706
+ * @param {SlotState} state
707
+ * @param {HTMLSlotElement} slot
708
+ * @param {Node[]} assigned
709
+ * @returns {boolean} True if the slot's assignment changed compared to
710
+ * its last snapshot (so slotchange should fire).
711
+ */
712
+ function applyActualAssignment(state, slot, assigned) {
713
+ const wasFallback = slot.getAttribute(PROJECTION_ATTR) !== PROJECTION_ACTUAL;
714
+ const prev = state.lastSnapshot.get(slot) || [];
715
+ const equal = !wasFallback && arraysEqual(prev, assigned);
716
+ if (equal) return false;
717
+
718
+ // Preserve fallback content. If the slot currently holds fallback nodes
719
+ // (either because we just hydrated from SSR's data-projection="fallback"
720
+ // or because the slot-part placed them there at bind time), move them
721
+ // back into the part-owned holding fragment so they survive for a later
722
+ // transition. Identified via the SLOT_FALLBACK_FRAG symbol that the
723
+ // slot-part wrote to the element.
724
+ const fallbackFrag = /** @type {DocumentFragment | undefined} */ (
725
+ /** @type {any} */ (slot)[SLOT_FALLBACK_FRAG]
726
+ );
727
+ if (wasFallback && fallbackFrag) {
728
+ while (slot.firstChild) fallbackFrag.appendChild(slot.firstChild);
729
+ } else {
730
+ while (slot.firstChild) slot.removeChild(slot.firstChild);
731
+ }
732
+ for (const node of assigned) slot.appendChild(node);
733
+ slot.setAttribute(PROJECTION_ATTR, PROJECTION_ACTUAL);
734
+ state.lastSnapshot.set(slot, assigned.slice());
735
+ return true;
736
+ }
737
+
738
+ /**
739
+ * Set a slot to fallback mode. slot.js clears any actual-assignment
740
+ * children (moving them to pending for re-projection later) and sets the
741
+ * marker attribute. The compiled fallback template is restored by the
742
+ * slot-part's apply step in render-client.js, since the fallback content
743
+ * may include template holes that the renderer needs to bind.
744
+ *
745
+ * @param {SlotState} state
746
+ * @param {HTMLSlotElement} slot
747
+ * @returns {boolean} True if the slot transitioned from actual to
748
+ * fallback this pass.
749
+ */
750
+ function applyFallback(state, slot) {
751
+ const wasActual = slot.getAttribute(PROJECTION_ATTR) === PROJECTION_ACTUAL;
752
+ slot.setAttribute(PROJECTION_ATTR, PROJECTION_FALLBACK);
753
+ if (!wasActual) {
754
+ // Already fallback. Make sure the fallback content is materialised
755
+ // in the slot if the slot-part has a holding fragment with nodes.
756
+ restoreFallbackInto(slot);
757
+ return false;
758
+ }
759
+
760
+ // Slot transitioning from actual to fallback. This happens when the
761
+ // host's assignment for this slot's name is empty (e.g., user removed
762
+ // all matching children). Drop the lastSnapshot record; do NOT push
763
+ // children to the pending map (that path is for slot destruction
764
+ // during a conditional collapse, handled separately by
765
+ // moveSlotChildrenToPending).
766
+ state.lastSnapshot.delete(slot);
767
+ while (slot.firstChild) slot.removeChild(slot.firstChild);
768
+ restoreFallbackInto(slot);
769
+ return true;
770
+ }
771
+
772
+ /**
773
+ * Move the slot-part's holding fragment back into the slot. No-op if no
774
+ * fragment is attached or it is already empty.
775
+ *
776
+ * @param {HTMLSlotElement} slot
777
+ */
778
+ function restoreFallbackInto(slot) {
779
+ const frag = /** @type {DocumentFragment | undefined} */ (
780
+ /** @type {any} */ (slot)[SLOT_FALLBACK_FRAG]
781
+ );
782
+ if (!frag || frag.childNodes.length === 0) return;
783
+ slot.appendChild(frag);
784
+ }
785
+
786
+ /**
787
+ * Attach a MutationObserver to a slot's `name` attribute so re-targeting
788
+ * (via dynamic name change) triggers re-projection.
789
+ *
790
+ * @param {Element} host
791
+ * @param {HTMLSlotElement} slot
792
+ */
793
+ function attachNameObserver(host, slot) {
794
+ if (!inBrowser) return;
795
+ const state = ensureSlotState(host);
796
+ if (state.nameObservers.has(slot)) return;
797
+ const obs = new MutationObserver(() => scheduleProjection(host));
798
+ obs.observe(slot, { attributes: true, attributeFilter: ['name'] });
799
+ state.nameObservers.set(slot, obs);
800
+ }
801
+
802
+ // ---------------------------------------------------------------------------
803
+ // Slot-part teardown hook (called from render-client.js)
804
+ // ---------------------------------------------------------------------------
805
+
806
+ /**
807
+ * Move a slot's currently-projected children to the host's pending map
808
+ * before the slot DOM element is removed by the framework's template
809
+ * teardown (e.g., a conditional fragment collapsing). Called by
810
+ * render-client.js immediately before disposing the slot-part.
811
+ *
812
+ * Without this hook the children would be torn down by the renderer's
813
+ * generic clearInstance() and lose DOM identity.
814
+ *
815
+ * @param {Element} host
816
+ * @param {HTMLSlotElement} slot
817
+ */
818
+ export function moveSlotChildrenToPending(host, slot) {
819
+ if (!hasSlotState(host)) return;
820
+ const state = ensureSlotState(host);
821
+ const projected = state.lastSnapshot.get(slot);
822
+ if (!projected || projected.length === 0) return;
823
+ const name = slot.getAttribute('name') || null;
824
+ // Detach nodes from the slot so the renderer's clearInstance doesn't
825
+ // dispose them.
826
+ for (const node of projected) {
827
+ if (node.parentNode === slot) slot.removeChild(node);
828
+ }
829
+ appendArrayToMap(state.pendingByName, name, projected);
830
+ state.lastSnapshot.delete(slot);
831
+ const obs = state.nameObservers.get(slot);
832
+ if (obs) {
833
+ obs.disconnect();
834
+ state.nameObservers.delete(slot);
835
+ }
836
+ state.ownedSlots.delete(slot);
837
+ }
838
+
839
+ // ---------------------------------------------------------------------------
840
+ // slotchange event dispatch
841
+ // ---------------------------------------------------------------------------
842
+
843
+ /** Fire a `slotchange` event on the slot (bubbles, not composed; per spec). */
844
+ function fireSlotChange(slot) {
845
+ slot.dispatchEvent(new Event('slotchange', { bubbles: true, composed: false }));
846
+ }
847
+
848
+ // ---------------------------------------------------------------------------
849
+ // Misc helpers
850
+ // ---------------------------------------------------------------------------
851
+
852
+ /** Strict per-index equality on two arrays. */
853
+ function arraysEqual(a, b) {
854
+ if (a === b) return true;
855
+ if (a.length !== b.length) return false;
856
+ for (let i = 0; i < a.length; i++) {
857
+ if (a[i] !== b[i]) return false;
858
+ }
859
+ return true;
860
+ }