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