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