@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/README.md +55 -0
- package/index.d.ts +28 -0
- package/index.js +39 -0
- package/package.json +46 -0
- package/src/component.d.ts +120 -0
- package/src/component.js +1061 -0
- package/src/context.js +327 -0
- package/src/css.js +54 -0
- package/src/directives.js +454 -0
- package/src/escape.js +15 -0
- package/src/expose.js +90 -0
- package/src/html.js +30 -0
- package/src/lazy-loader.js +107 -0
- package/src/nav.js +45 -0
- package/src/registry.js +190 -0
- package/src/render-client.js +1717 -0
- package/src/render-server.js +1128 -0
- package/src/repeat.js +37 -0
- package/src/rich-fetch.js +70 -0
- package/src/router-client.js +1434 -0
- package/src/serialize.js +452 -0
- package/src/signal.js +684 -0
- package/src/slot.js +860 -0
- package/src/suspense.js +46 -0
- package/src/task.js +386 -0
- package/src/testing.js +157 -0
- package/src/webjs-frame.js +65 -0
- package/src/websocket-client.js +111 -0
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
|
+
}
|