@webjsdev/core 0.7.50 → 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.
Files changed (57) hide show
  1. package/dist/webjs-core-browser.js +5 -5
  2. package/dist/webjs-core-browser.js.map +3 -3
  3. package/dist/webjs-core-testing.js +20 -20
  4. package/dist/webjs-core-testing.js.map +3 -3
  5. package/dist/webjs-core.js +7 -7
  6. package/dist/webjs-core.js.map +3 -3
  7. package/index-browser.js +1 -1
  8. package/index.d.ts +1 -1
  9. package/index.js +1 -1
  10. package/package.json +1 -1
  11. package/src/component/lifecycle.js +1481 -0
  12. package/src/component/reactive.js +171 -0
  13. package/src/component/server-element.js +248 -0
  14. package/src/component.js +2 -1896
  15. package/src/render-client/parts.js +2007 -0
  16. package/src/render-client/reconciler.js +388 -0
  17. package/src/render-client/template-compiler.js +525 -0
  18. package/src/render-client.js +1 -2938
  19. package/src/render-server/attrs.js +160 -0
  20. package/src/render-server/dsd.js +445 -0
  21. package/src/render-server/html-scan.js +402 -0
  22. package/src/render-server/slots.js +213 -0
  23. package/src/render-server/stream.js +163 -0
  24. package/src/render-server/template-renderer.js +923 -0
  25. package/src/render-server/text.js +163 -0
  26. package/src/render-server.js +6 -2405
  27. package/src/router-client/anchors.js +44 -0
  28. package/src/router-client/boundaries.js +221 -0
  29. package/src/router-client/constants.js +187 -0
  30. package/src/router-client/diagnostics.js +377 -0
  31. package/src/router-client/dom-differ.js +709 -0
  32. package/src/router-client/dom-parse.js +150 -0
  33. package/src/router-client/events.js +139 -0
  34. package/src/router-client/fetch-apply.js +387 -0
  35. package/src/router-client/form-encoder.js +135 -0
  36. package/src/router-client/frames.js +329 -0
  37. package/src/router-client/head-merge.js +344 -0
  38. package/src/router-client/nav-error.js +105 -0
  39. package/src/router-client/navigator.js +781 -0
  40. package/src/router-client/prefetch.js +824 -0
  41. package/src/router-client/scroll.js +259 -0
  42. package/src/router-client/snapshot-cache.js +97 -0
  43. package/src/router-client/state.js +92 -0
  44. package/src/router-client/stream.js +206 -0
  45. package/src/router-client/swap.js +451 -0
  46. package/src/router-client/upgrade.js +261 -0
  47. package/src/router-client/view-transition.js +223 -0
  48. package/src/router-client.d.ts +10 -1
  49. package/src/router-client.js +75 -5345
  50. package/src/slot/assignment.js +304 -0
  51. package/src/slot/interception.js +558 -0
  52. package/src/slot/polyfills.js +365 -0
  53. package/src/slot/project.js +432 -0
  54. package/src/slot/sensors.js +303 -0
  55. package/src/slot/state.js +245 -0
  56. package/src/slot/symbols.js +85 -0
  57. package/src/slot.js +7 -2199
@@ -0,0 +1,709 @@
1
+ /**
2
+ * Client router: dom-differ.
3
+ *
4
+ * Moved verbatim out of the pre-split `router-client.js`; that barrel holds
5
+ * the router's full contract and the public entry points.
6
+ *
7
+ * @module
8
+ */
9
+ import { LIGHT_SLOT_ATTR, PROJECTION_ACTUAL, PROJECTION_ATTR, SLOT_STATE, isAuthoredContentSlot, keyOfName, projectAuthored } from '../slot.js';
10
+ import { buildHaveHeader, collectBoundaries } from './boundaries.js';
11
+ import { LIVE_ATTRS } from './constants.js';
12
+
13
+ import { currentNavigationToken } from './state.js';
14
+ import { activateSwappedRange } from './upgrade.js';
15
+ import { regraftPermanentInSlice } from './view-transition.js';
16
+
17
+ /**
18
+ * After a swap, blur whatever element the user activated to trigger the
19
+ * navigation (the clicked sidenav link, the submitted form button, etc.).
20
+ *
21
+ * Why: browsers paint `:focus-visible` rings when the window regains
22
+ * focus on whatever has focus at that moment. A click leaves focus on
23
+ * the clicked element, so without this blur the user sees a stuck focus
24
+ * ring on the sidenav link every time they switch workspaces and come
25
+ * back: even though they navigated minutes ago.
26
+ *
27
+ * We do NOT programmatically move focus to the new page's h1/h2.
28
+ * That'd just relocate the same problem (focus ring on the heading
29
+ * after a workspace switch) and steals focus from sighted users.
30
+ * Screen-reader users navigate by heading via their own shortcuts
31
+ * (`h` in NVDA/JAWS), so they don't need us to do it for them.
32
+ *
33
+ * No-op when focus is on `<body>` (browser default after `removeChild`
34
+ * of a focused node) or when the active element survived the swap and
35
+ * was inside the new content (means the swap was internal to a region
36
+ * the user was already interacting with: don't fight them).
37
+ */
38
+ export function blurOutgoingFocus() {
39
+ const a = document.activeElement;
40
+ if (!a || a === document.body || a === document.documentElement) return;
41
+ if (typeof (/** @type any */ (a).blur) !== 'function') return;
42
+ /** @type any */ (a).blur();
43
+ }
44
+
45
+ /**
46
+ * Wholesale-REPLACE the contents of a boundary range (#1015): remove every
47
+ * live node between `target.start` and `target.end` (exclusive) and insert a
48
+ * fresh import of the incoming range. This is the REMOUNT tier: the boundary's
49
+ * route-key changed (a param change, or a different page under a shared static
50
+ * layout), so Next.js parity demands fresh component instances, not a keyed
51
+ * reuse of old-page DOM. The only nodes that survive by identity are
52
+ * `data-webjs-permanent` elements, regrafted into the imported slice before
53
+ * insertion so a playing `<audio>`/widget keeps running.
54
+ *
55
+ * @param {{ start: Comment, end: Comment }} target The live boundary.
56
+ * @param {{ start: Comment, end: Comment }} source The incoming boundary.
57
+ */
58
+ export function replaceBoundaryRange(target, source) {
59
+ const liveParent = target.start.parentNode;
60
+ if (!liveParent) return;
61
+ /** @type {Node[]} */
62
+ const liveSlice = [];
63
+ for (let n = target.start.nextSibling; n && n !== target.end; n = n.nextSibling) {
64
+ liveSlice.push(n);
65
+ }
66
+ /** @type {Node[]} */
67
+ const incomingSlice = [];
68
+ for (let n = source.start.nextSibling; n && n !== source.end; n = n.nextSibling) {
69
+ incomingSlice.push(document.importNode(n, true));
70
+ }
71
+ regraftPermanentInSlice(liveSlice, incomingSlice);
72
+ for (const n of liveSlice) {
73
+ if (n.parentNode === liveParent) liveParent.removeChild(n);
74
+ }
75
+ for (const n of incomingSlice) {
76
+ liveParent.insertBefore(n, target.end);
77
+ }
78
+ activateSwappedRange(target);
79
+ }
80
+
81
+ /**
82
+ * MORPH the contents of a boundary range (#1015): reconcile the nodes between
83
+ * `target.start` and `target.end` (exclusive) in the live document against the
84
+ * nodes between `source.start` and `source.end` in the parsed Document, using
85
+ * a keyed reconciler that preserves DOM identity for matched elements + their
86
+ * live attributes (scroll, value, etc.). This is the state-preserving tier for
87
+ * a searchParams-only / refresh nav, where the route-key is unchanged and
88
+ * hydrated component state must survive.
89
+ *
90
+ * Boundaries are strictly paired by the scanner (#1015), so `end` is always a
91
+ * real close comment here (the null-orphan tolerance of the deleted #994
92
+ * recovery is gone with it).
93
+ *
94
+ * @param {{ start: Comment, end: Comment } | undefined} target
95
+ * @param {{ start: Comment, end: Comment } | undefined} source
96
+ * @param {Document} _doc
97
+ */
98
+ export function swapMarkerRange(target, source, _doc) {
99
+ if (!target || !source) return;
100
+
101
+ // Build a parent-with-matching-children pair for the keyed differ.
102
+ // The differ wants two parents: synthesize a transient parent for
103
+ // the slice of `source` so we can diff in-place against `target.start`
104
+ // / `target.end` siblings on the live document.
105
+ const liveParent = target.start.parentNode;
106
+ if (!liveParent) return;
107
+
108
+ // Collect current children (nodes between start and end, exclusive).
109
+ /** @type {Node[]} */
110
+ const liveSlice = [];
111
+ for (let n = target.start.nextSibling; n && n !== target.end; n = n.nextSibling) {
112
+ liveSlice.push(n);
113
+ }
114
+
115
+ // Collect incoming children, importing into the live document.
116
+ /** @type {Node[]} */
117
+ const incomingSlice = [];
118
+ for (let n = source.start.nextSibling; n && n !== source.end; n = n.nextSibling) {
119
+ incomingSlice.push(document.importNode(n, true));
120
+ }
121
+
122
+ // Persist permanent elements by node identity: regraft each live
123
+ // [data-webjs-permanent][id] node into the matching position in the
124
+ // imported incoming slice, replacing the freshly-imported copy, so the
125
+ // keyed reconciler adopts the live node instead of destroying it.
126
+ regraftPermanentInSlice(liveSlice, incomingSlice);
127
+
128
+ // Run the keyed diff.
129
+ reconcileSiblings(liveParent, target.start, target.end, liveSlice, incomingSlice);
130
+
131
+ // Upgrade + activate scripts in the just-swapped range. A top-level script
132
+ // here is usually one the keyed reconciler REUSED (`keyOf` reads
133
+ // `data-key || id`), so it re-executes on every soft nav that morphs this
134
+ // boundary. That is deliberate: a descendant script inside a reused
135
+ // container has always re-run through this same pass, and a script's
136
+ // position in the range is not a reason to treat it differently (#1102).
137
+ activateSwappedRange(target);
138
+ }
139
+
140
+ /**
141
+ * Coarse keyed reconciliation between liveSlice and incomingSlice,
142
+ * positioned in liveParent between `startMarker` and `endMarker`.
143
+ *
144
+ * Algorithm (Remix v3 inspired, pared down):
145
+ * - Match elements by (tagName + key) where key = data-key || id.
146
+ * - For each pair: diff attributes, recurse into children.
147
+ * - Unmatched live elements: remove.
148
+ * - Unmatched incoming elements: insert in the right slot.
149
+ * - Live attributes (value, checked, open, scroll-position) are
150
+ * preserved on matched elements regardless of server HTML.
151
+ *
152
+ * This is intentionally simple: when no keys are present, the diff
153
+ * matches by position only and falls back to replaceChildren-like
154
+ * semantics for the unkeyed range. Apps that want stronger
155
+ * preservation add `data-key` to elements they care about.
156
+ *
157
+ * @param {Node} parent
158
+ * @param {Comment} startMarker
159
+ * @param {Comment | null} endMarker Null (recovered orphan, #994) appends at the parent end.
160
+ * @param {Node[]} live
161
+ * @param {Node[]} incoming
162
+ */
163
+ export function reconcileSiblings(parent, startMarker, endMarker, live, incoming) {
164
+ // Index live elements by (tag + key) for keyed match.
165
+ /** @type {Map<string, Element>} */
166
+ const keyedLive = new Map();
167
+ for (const n of live) {
168
+ if (n.nodeType !== 1) continue;
169
+ const k = keyOf(/** @type {Element} */ (n));
170
+ if (k) keyedLive.set(k, /** @type {Element} */ (n));
171
+ }
172
+
173
+ // Walk incoming, placing nodes in order between markers.
174
+ /** @type {Node} */
175
+ let insertBefore = endMarker;
176
+ // First pass: build the final ordered list of nodes (reusing matched live).
177
+ /** @type {Node[]} */
178
+ const finalNodes = [];
179
+ for (const inc of incoming) {
180
+ if (inc.nodeType === 1) {
181
+ const k = keyOf(/** @type {Element} */ (inc));
182
+ if (k && keyedLive.has(k)) {
183
+ const reused = keyedLive.get(k);
184
+ diffElementInPlace(reused, /** @type {Element} */ (inc));
185
+ finalNodes.push(reused);
186
+ keyedLive.delete(k);
187
+ continue;
188
+ }
189
+ }
190
+ finalNodes.push(inc);
191
+ }
192
+
193
+ // Remove live nodes that weren't reused.
194
+ for (const n of live) {
195
+ if (n.parentNode === parent) {
196
+ if (n.nodeType === 1 && finalNodes.includes(n)) continue;
197
+ parent.removeChild(n);
198
+ }
199
+ }
200
+
201
+ // Insert final nodes in order before the end marker.
202
+ for (const n of finalNodes) {
203
+ parent.insertBefore(n, insertBefore);
204
+ }
205
+ }
206
+
207
+ /**
208
+ * Diff one matched element in place: copy attributes from `src` to `dst`,
209
+ * preserve live attributes, recurse into children.
210
+ *
211
+ * @param {Element} dst The element to update (live DOM).
212
+ * @param {Element} src The element to copy from (incoming HTML).
213
+ */
214
+ export function diffElementInPlace(dst, src) {
215
+ // A regrafted `data-webjs-permanent` node is the SAME node on both
216
+ // sides (the live node was moved into the incoming tree). Diffing it
217
+ // against itself would recurse into its own children and re-import
218
+ // them; instead leave it exactly as the user left it (that is the whole
219
+ // point of permanence).
220
+ if (dst === src) return;
221
+ if (dst.tagName !== src.tagName) {
222
+ dst.replaceWith(src);
223
+ return;
224
+ }
225
+ // Update attributes from src; remove ones not in src.
226
+ const srcAttrs = new Set();
227
+ for (const attr of src.attributes) {
228
+ srcAttrs.add(attr.name);
229
+ if (LIVE_ATTRS.has(attr.name)) continue;
230
+ // The serialized-restore stamp is a message to a NOT-YET-UPGRADED
231
+ // element's connectedCallback; copying it onto a live reused host would
232
+ // leave a consume-once marker lingering in the live DOM forever. Note
233
+ // the REMOVAL loop below never strips an existing stamp either (the
234
+ // stamp is in srcAttrs, added before this skip) and that retention is
235
+ // load-bearing: a not-yet-upgraded `static lazy` host must KEEP its
236
+ // stamp across an intervening morph so its late upgrade still adopts.
237
+ if (attr.name === 'data-wj-serialized') continue;
238
+ if (dst.getAttribute(attr.name) !== attr.value) {
239
+ dst.setAttribute(attr.name, attr.value);
240
+ }
241
+ }
242
+ for (const attr of [...dst.attributes]) {
243
+ if (LIVE_ATTRS.has(attr.name)) continue;
244
+ if (!srcAttrs.has(attr.name)) dst.removeAttribute(attr.name);
245
+ }
246
+ // For form-control-like elements, preserve live IDL state.
247
+ // (`value`, `checked`, `open`, etc.: see LIVE_ATTRS below for full list.)
248
+ // The attribute version is skipped above; we deliberately do nothing
249
+ // here so the user's typing / checking is never blown away.
250
+
251
+ // A hydrated component OWNS its rendered subtree. The client renderer
252
+ // stashes the live template instance (lit-html parts holding DIRECT
253
+ // references to the rendered nodes) on the host under
254
+ // `Symbol.for('webjs.instance')`. Recursing into those children would
255
+ // import/remove/reorder the very nodes the parts still point at, so the
256
+ // component's next reactive update would write into detached nodes and
257
+ // silently do nothing (a dead click after a soft nav, #906). Treat the
258
+ // component as opaque: the attribute sync above already drove any reactive
259
+ // property change through `attributeChangedCallback`, so the component
260
+ // re-renders ITSELF; the router must not touch its internals. This mirrors
261
+ // Turbo/morphdom, which leave custom elements alone by default.
262
+ //
263
+ // One carve-out (#908): a light-DOM component's projected <slot> content is
264
+ // page-authored (moved into the slot by the slot runtime), NOT render-owned,
265
+ // so a reused component would otherwise keep showing STALE slotted content
266
+ // when the nav supplies different content. Re-project ONLY those slot
267
+ // children; the render-owned nodes stay untouched, so #906 does not regress.
268
+ if (isHydratedComponent(dst)) {
269
+ reprojectSlottedContent(dst, src);
270
+ return;
271
+ }
272
+
273
+ // Recurse into children: collect both sides, run reconcileSiblings on
274
+ // them with synthetic boundary markers. Cheap implementation: use
275
+ // virtual ranges instead of inserting real comment markers.
276
+ reconcileChildren(dst, src);
277
+ }
278
+
279
+ /**
280
+ * True when `el` carries a live client-side render instance, i.e. a webjs
281
+ * component whose `render()` produced the current children and owns them via
282
+ * lit-html parts. The router must not reconcile INTO such an element (#906).
283
+ *
284
+ * Detected via the render-client instance symbol rather than a `customElements`
285
+ * lookup so it fires only for elements that have actually rendered client-side:
286
+ * a not-yet-upgraded or purely display-only custom element (no client render,
287
+ * no parts to corrupt) stays fully reconcilable.
288
+ *
289
+ * @param {Element} el
290
+ * @returns {boolean}
291
+ */
292
+ function isHydratedComponent(el) {
293
+ // Opaque to the router when it has rendered (INSTANCE) OR merely has slot
294
+ // state installed but has not yet run its deferred first render (SLOT_STATE):
295
+ // in that window a same-task morph would otherwise reconcile INTO the host
296
+ // through the slot interception.
297
+ const a = /** @type {any} */ (el);
298
+ return a[Symbol.for('webjs.instance')] != null || a[SLOT_STATE] != null;
299
+ }
300
+
301
+ /**
302
+ * True when `slot` belongs directly to `host`, i.e. no OTHER custom element
303
+ * sits between them. A slot nested inside a child custom element belongs to
304
+ * THAT component (its own slot state owns it), so the host must not touch it.
305
+ *
306
+ * @param {Element} slot
307
+ * @param {Element} host
308
+ * @returns {boolean}
309
+ */
310
+ function isOwnLightSlot(slot, host) {
311
+ for (let p = slot.parentElement; p && p !== host; p = p.parentElement) {
312
+ if (p.tagName.includes('-')) return false;
313
+ }
314
+ return true;
315
+ }
316
+
317
+ /**
318
+ * Group a component's own `data-projection="actual"` light slots by name,
319
+ * first-wins (mirroring the slot runtime + SSR first-wins rule). Slots nested
320
+ * inside a child custom element are excluded (they belong to that child).
321
+ *
322
+ * @param {Element} host
323
+ * @returns {Map<string|null, HTMLSlotElement>}
324
+ */
325
+ function ownActualLightSlots(host) {
326
+ /** @type {Map<string|null, HTMLSlotElement>} */
327
+ const byName = new Map();
328
+ const sel = `slot[${LIGHT_SLOT_ATTR}][${PROJECTION_ATTR}="${PROJECTION_ACTUAL}"]`;
329
+ for (const slot of host.querySelectorAll(sel)) {
330
+ const s = /** @type {HTMLSlotElement} */ (slot);
331
+ if (!isOwnLightSlot(s, host)) continue;
332
+ // The runtime's invariant applies here too: a slot inside AUTHORED
333
+ // content (an author-relocated rendered chunk, a spoofed stamp) is
334
+ // inert content, never a reprojection target; collecting it would
335
+ // project into (or evict from) a slot the apply refuses to place.
336
+ if (isAuthoredContentSlot(host, s)) continue;
337
+ // Src-side (parsed doc) hosts have no record, so the authored test is
338
+ // inert there; the SERIALIZED shape of content is structural instead: a
339
+ // slot nested inside an ACTUAL-mode light slot of the same host is
340
+ // content (an SSR'd forwarded slot rides inside the inner host's own
341
+ // actual slot), never a reprojection target. A slot inside a
342
+ // FALLBACK-mode container stays collectable: fallback content is
343
+ // template markup, and a slot there is legitimate.
344
+ let nestedInActual = false;
345
+ for (let a = s.parentElement; a && a !== host; a = a.parentElement) {
346
+ if (
347
+ a.tagName === 'SLOT' &&
348
+ a.hasAttribute(LIGHT_SLOT_ATTR) &&
349
+ a.getAttribute(PROJECTION_ATTR) === PROJECTION_ACTUAL
350
+ ) {
351
+ nestedInActual = true;
352
+ break;
353
+ }
354
+ }
355
+ if (nestedInActual) continue;
356
+ const name = keyOfName(s.getAttribute('name'));
357
+ if (!byName.has(name)) byName.set(name, s);
358
+ }
359
+ return byName;
360
+ }
361
+
362
+ /**
363
+ * Re-project the page-authored slotted content of a REUSED hydrated light-DOM
364
+ * component across a soft nav (#908), without touching its render-owned
365
+ * subtree.
366
+ *
367
+ * The #906 guard treats a hydrated component as opaque so the router never
368
+ * corrupts its lit-html-owned nodes. But the projected children inside a
369
+ * light-DOM `<slot data-webjs-light data-projection="actual">` are
370
+ * page-authored (moved there by the slot runtime), NOT held by lit-html parts,
371
+ * so reconciling ONLY those children is safe and cannot reintroduce #906. Both
372
+ * the live DOM and the incoming SSR HTML carry the same slot markers
373
+ * (render-server emits them), so slots pair up by name + document order.
374
+ *
375
+ * Three cases, by how a slot's projection state changes across the nav:
376
+ * - actual->actual (content changed): identity-preserving `reconcileChildren`
377
+ * on the page-authored slot children, exactly as #908 shipped.
378
+ * - actual->fallback (content REMOVED) and fallback->actual (content ADDED):
379
+ * a slot's fallback is RENDER-OWNED (the compiled fallback template held by
380
+ * the slot-part), so these are NOT a raw reconcile. All three cases route
381
+ * through `projectAuthored`, the record seam, whose apply pass restores or
382
+ * swaps the render-owned fallback without reconciling any lit-html part
383
+ * (#912). The #906 one-level-down hazard (this component's assignment
384
+ * reaching a nested child's same-named slot) is answered by the apply
385
+ * pass's own-slot filtering (`isOwnSlot` + the authored-content
386
+ * exclusion), not by surgical single-slot application.
387
+ *
388
+ * @param {Element} dst Live hydrated component host.
389
+ * @param {Element} src Incoming SSR copy of the same component.
390
+ */
391
+ /**
392
+ * After a boundary swap, if the swapped range's parent is a light-DOM slot,
393
+ * resync the owning host's slot record from the slot's REAL children through
394
+ * the one public seam (`projectAuthored`). The router's raw range write is the
395
+ * one sanctioned write into a region the slot runtime also places (a layout's
396
+ * `${children}` rendered inside a slotted shell puts the `wj:children` markers
397
+ * INSIDE that shell's slot), so without this sync the record goes stale and
398
+ * the host's next apply would wipe the swapped-in page content and restore the
399
+ * pruned old list. Walking up from the slot, the owner is the nearest
400
+ * `SLOT_STATE` host with no other custom element in between; anything else
401
+ * (a nested stateless component, a shadow slot) bails.
402
+ *
403
+ * @param {Comment} startMarker
404
+ */
405
+ function resyncEnclosingSlotRecord(startMarker) {
406
+ const p = startMarker.parentNode;
407
+ if (!p || p.nodeType !== 1) return;
408
+ const slotEl = /** @type {Element} */ (p);
409
+ if (slotEl.tagName !== 'SLOT' || !slotEl.hasAttribute(LIGHT_SLOT_ATTR)) return;
410
+ let host = null;
411
+ for (let a = slotEl.parentElement; a; a = a.parentElement) {
412
+ if (/** @type {any} */ (a)[SLOT_STATE]) { host = a; break; }
413
+ if (a.tagName.includes('-')) return; // belongs to a stateless nested element
414
+ }
415
+ if (!host) return;
416
+ projectAuthored(host, keyOfName(slotEl.getAttribute('name')), [...slotEl.childNodes]);
417
+ }
418
+
419
+ /**
420
+ * Resync the ENCLOSING HOST's slots after a boundary swap whose markers live
421
+ * inside a light-DOM slot (a layout whose `${children}` render inside a
422
+ * slotted shell). The boundary swap only rewrites the DEFAULT slice (the
423
+ * `wj:children` markers always partition to the default slot), so a page that
424
+ * emits top-level `slot=`-attributed children left the shell's NAMED slots
425
+ * showing the previous page's content (#1024). This resyncs the enclosing
426
+ * (default) slot from its just-swapped LIVE children, then reprojects the
427
+ * sibling NAMED slots from the INCOMING parsed host.
428
+ *
429
+ * @param {Comment} liveStart
430
+ * @param {Comment} incStart
431
+ */
432
+ export function resyncEnclosingHostSlots(liveStart, incStart) {
433
+ const lp = liveStart.parentNode;
434
+ if (!lp || lp.nodeType !== 1) return;
435
+ const liveSlot = /** @type {Element} */ (lp);
436
+ if (liveSlot.tagName !== 'SLOT' || !liveSlot.hasAttribute(LIGHT_SLOT_ATTR)) return;
437
+ let liveHost = null;
438
+ for (let a = liveSlot.parentElement; a; a = a.parentElement) {
439
+ if (/** @type {any} */ (a)[SLOT_STATE]) { liveHost = a; break; }
440
+ if (a.tagName.includes('-')) return; // enclosing slot belongs to a stateless nested element
441
+ }
442
+ if (!liveHost) return;
443
+ const enclosingName = keyOfName(liveSlot.getAttribute('name'));
444
+ // 1. The enclosing (boundary) slot, from its own just-swapped live children.
445
+ projectAuthored(liveHost, enclosingName, [...liveSlot.childNodes]);
446
+
447
+ // 2. The sibling NAMED slots, from the incoming parsed host. Find the
448
+ // incoming host structurally (the parsed copy is not upgraded, so no
449
+ // SLOT_STATE): the nearest custom-element ancestor of the incoming
450
+ // boundary marker's enclosing slot.
451
+ const ip = incStart.parentNode;
452
+ if (!ip || ip.nodeType !== 1) return;
453
+ const incSlot = /** @type {Element} */ (ip);
454
+ if (incSlot.tagName !== 'SLOT') return;
455
+ let incHost = null;
456
+ for (let a = incSlot.parentElement; a; a = a.parentElement) {
457
+ if (a.tagName.includes('-')) { incHost = a; break; }
458
+ }
459
+ if (!incHost || incHost.tagName !== liveHost.tagName) return;
460
+
461
+ const liveSlots = ownActualLightSlots(liveHost);
462
+ const incSlots = ownActualLightSlots(incHost);
463
+ for (const name of new Set([...liveSlots.keys(), ...incSlots.keys()])) {
464
+ if (name === enclosingName) continue; // handled in (1); never re-reconcile the swapped range
465
+ const inc = incSlots.get(name);
466
+ if (inc) {
467
+ projectAuthored(
468
+ liveHost,
469
+ name,
470
+ [...inc.childNodes].map((n) => document.importNode(n, true)),
471
+ );
472
+ } else if (liveSlots.get(name)) {
473
+ projectAuthored(liveHost, name, null); // dropped by the incoming page: revert to fallback
474
+ }
475
+ }
476
+ }
477
+
478
+ function reprojectSlottedContent(dst, src) {
479
+ // Only a light-DOM component that tracks slot assignments has placed
480
+ // page-authored content to update. No slot state (no <slot>, or a shadow-DOM
481
+ // component whose slotted nodes are ordinary light children) means nothing
482
+ // to update here.
483
+ if (!(/** @type {any} */ (dst)[SLOT_STATE])) return;
484
+
485
+ const liveSlots = ownActualLightSlots(dst);
486
+ const incSlots = ownActualLightSlots(src);
487
+ if (liveSlots.size === 0 && incSlots.size === 0) return;
488
+
489
+ // #1015: slotted children are VALUES pushed through the ONE public seam,
490
+ // projectAuthored (no cross-module state surgery; the slot runtime owns the
491
+ // record, fires slotchange on a genuine set change, and re-applies). The
492
+ // union walk covers boundary transitions (a name present on only one side).
493
+ const names = new Set([...liveSlots.keys(), ...incSlots.keys()]);
494
+ for (const name of names) {
495
+ const liveSlot = liveSlots.get(name);
496
+ const incSlot = incSlots.get(name);
497
+ if (liveSlot && incSlot) {
498
+ // actual->actual: reconcile IN PLACE first so page-authored slotted
499
+ // nodes keep DOM identity where they match (#908: a nested live
500
+ // component survives; an in-place text edit reuses the same node),
501
+ // then push the resulting set through the public API. The runtime's
502
+ // set-equality check makes slotchange fire exactly on an
503
+ // add/remove/replace and stay silent on a pure text edit (#912).
504
+ reconcileChildren(liveSlot, incSlot);
505
+ projectAuthored(dst, name, [...liveSlot.childNodes]);
506
+ } else if (incSlot) {
507
+ // fallback->actual: incoming ADDED content. Import and push.
508
+ projectAuthored(dst, name, [...incSlot.childNodes].map((n) => document.importNode(n, true)));
509
+ } else {
510
+ // actual->fallback: incoming DROPPED the content. Reset to fallback.
511
+ projectAuthored(dst, name, null);
512
+ }
513
+ }
514
+ }
515
+
516
+ /**
517
+ * Reconcile dst's children to match src's children, in-place.
518
+ *
519
+ * @param {Element} dst
520
+ * @param {Element} src
521
+ */
522
+ export function reconcileChildren(dst, src) {
523
+ const liveChildren = [...dst.childNodes];
524
+ const incomingChildren = [...src.childNodes].map((n) => document.importNode(n, true));
525
+
526
+ // Persist `data-webjs-permanent` elements by node identity: regraft each
527
+ // live permanent node into the matching position in the freshly-imported
528
+ // incoming children (replacing the imported copy), so the keyed match
529
+ // below adopts the LIVE node and the reconciler never recreates it. This
530
+ // is the in-region (frame + nested) counterpart of the full-body and
531
+ // marker-range regrafts; running it here covers permanents nested below
532
+ // the top keyed level too.
533
+ regraftPermanentInSlice(liveChildren, incomingChildren);
534
+
535
+ // Build keyed map of live children for reuse.
536
+ /** @type {Map<string, Element>} */
537
+ const keyedLive = new Map();
538
+ for (const n of liveChildren) {
539
+ if (n.nodeType !== 1) continue;
540
+ const k = keyOf(/** @type {Element} */ (n));
541
+ if (k) keyedLive.set(k, /** @type {Element} */ (n));
542
+ }
543
+
544
+ /** @type {Node[]} */
545
+ const finalNodes = [];
546
+ for (let i = 0; i < incomingChildren.length; i++) {
547
+ const inc = incomingChildren[i];
548
+ if (inc.nodeType === 1) {
549
+ const k = keyOf(/** @type {Element} */ (inc));
550
+ if (k && keyedLive.has(k)) {
551
+ const reused = keyedLive.get(k);
552
+ diffElementInPlace(reused, /** @type {Element} */ (inc));
553
+ finalNodes.push(reused);
554
+ keyedLive.delete(k);
555
+ continue;
556
+ }
557
+ // Positional match: same tag, same index, neither has a key.
558
+ const livePeer = liveChildren[i];
559
+ if (livePeer && livePeer.nodeType === 1 &&
560
+ !keyOf(/** @type {Element} */ (livePeer)) &&
561
+ /** @type {Element} */ (livePeer).tagName === /** @type {Element} */ (inc).tagName) {
562
+ diffElementInPlace(/** @type {Element} */ (livePeer), /** @type {Element} */ (inc));
563
+ finalNodes.push(livePeer);
564
+ continue;
565
+ }
566
+ } else if (inc.nodeType === 3) {
567
+ // Text node: positional reuse for stable identity.
568
+ const livePeer = liveChildren[i];
569
+ if (livePeer && livePeer.nodeType === 3) {
570
+ if (livePeer.nodeValue !== inc.nodeValue) livePeer.nodeValue = inc.nodeValue;
571
+ finalNodes.push(livePeer);
572
+ continue;
573
+ }
574
+ } else if (inc.nodeType === 8) {
575
+ // Comment: positional reuse.
576
+ const livePeer = liveChildren[i];
577
+ if (livePeer && livePeer.nodeType === 8) {
578
+ if (livePeer.nodeValue !== inc.nodeValue) livePeer.nodeValue = inc.nodeValue;
579
+ finalNodes.push(livePeer);
580
+ continue;
581
+ }
582
+ }
583
+ finalNodes.push(inc);
584
+ }
585
+
586
+ // Mutate dst to contain finalNodes in order, preserving reused references.
587
+ // Walk forward, inserting each node before the (potentially moved) next sibling.
588
+ const finalSet = new Set(finalNodes);
589
+ for (const n of liveChildren) {
590
+ if (!finalSet.has(n) && n.parentNode === dst) dst.removeChild(n);
591
+ }
592
+ for (let i = 0; i < finalNodes.length; i++) {
593
+ const n = finalNodes[i];
594
+ if (n.parentNode !== dst || dst.childNodes[i] !== n) {
595
+ dst.insertBefore(n, dst.childNodes[i] || null);
596
+ }
597
+ }
598
+ }
599
+
600
+ /**
601
+ * Get the diff key for an element: `data-key` if present, else `id`.
602
+ * Returns null for elements with no stable key.
603
+ *
604
+ * @param {Element} el
605
+ * @returns {string | null}
606
+ */
607
+ export function keyOf(el) {
608
+ const k = el.getAttribute('data-key');
609
+ if (k) return `${el.tagName}:k:${k}`;
610
+ if (el.id) return `${el.tagName}:i:${el.id}`;
611
+ return null;
612
+ }
613
+
614
+ /**
615
+ * Look for `<template id="wj-loading:<deepest-current-path>">` in the
616
+ * document; if present, clone its content into the deepest current
617
+ * children-slot. Returns state needed to restore on fetch failure.
618
+ *
619
+ * The returned state carries the nav-token in effect at swap time;
620
+ * `restoreOptimistic` verifies the token still matches before reverting,
621
+ * so a slow nav A's late failure cannot revert a faster nav B's
622
+ * already-settled state.
623
+ *
624
+ * @returns {{ slot: { start: Comment, end: Comment }, oldChildren: Node[], token: number } | null}
625
+ */
626
+ export function applyOptimisticLoading() {
627
+ const slots = collectBoundaries(document.body);
628
+ if (!slots || slots.size === 0) return null;
629
+ // Walk boundaries deepest-first and use the first whose segment has a
630
+ // loading template. Loading templates are keyed by LAYOUT segment
631
+ // (loading.ts files live next to layouts), while the deepest boundary is
632
+ // usually the PAGE's own (#1015), which has no template: skipping over it
633
+ // finds the innermost layout skeleton, matching the pre-#1015 behaviour.
634
+ const bySegment = [...slots.keys()].sort((a, b) => b.length - a.length);
635
+ let deepest = null;
636
+ let tpl = null;
637
+ for (const p of bySegment) {
638
+ const t = document.getElementById(`wj-loading:${p}`);
639
+ if (t instanceof HTMLTemplateElement) { deepest = p; tpl = t; break; }
640
+ }
641
+ if (deepest === null || tpl === null) return null;
642
+
643
+ const slot = slots.get(deepest);
644
+ // Snapshot the boundary keys BEFORE the skeleton wipes them (#1114). The
645
+ // range below deletes everything between this slot's markers, which includes
646
+ // every NESTED boundary comment, so `buildHaveHeader()` afterwards is
647
+ // legitimately shorter than the page really is. `prefetchTake` validates a
648
+ // cached fragment's anchor against the live boundaries, and without this it
649
+ // would judge against the skeleton's truncated view: on an app whose only
650
+ // loading.{js,ts} sits at the root, every deeper anchor vanishes and NO
651
+ // prefetch is ever consumable. Carried on the state that already threads to
652
+ // fetchAndApply, so nothing new has to be plumbed.
653
+ const haveKeys = buildHaveHeader();
654
+ /** @type {Node[]} */
655
+ const oldChildren = [];
656
+ for (let n = slot.start.nextSibling; n && n !== slot.end; n = n.nextSibling) {
657
+ oldChildren.push(n);
658
+ }
659
+ // Replace slot contents with the loading template.
660
+ const range = document.createRange();
661
+ range.setStartAfter(slot.start);
662
+ range.setEndBefore(slot.end);
663
+ range.deleteContents();
664
+ slot.start.parentNode.insertBefore(tpl.content.cloneNode(true), slot.end);
665
+ return { slot, oldChildren, token: currentNavigationToken, haveKeys };
666
+ }
667
+
668
+ /** @param {{ slot: { start: Comment, end: Comment }, oldChildren: Node[], token: number, haveKeys?: string } | null} state */
669
+ export function restoreOptimistic(state) {
670
+ if (!state) return;
671
+ // A newer nav superseded the one that captured this state: don't
672
+ // revert; that newer nav owns the page now.
673
+ if (state.token !== currentNavigationToken) return;
674
+ const { slot, oldChildren } = state;
675
+ if (slot.start.parentNode !== slot.end.parentNode) return;
676
+ const range = document.createRange();
677
+ range.setStartAfter(slot.start);
678
+ range.setEndBefore(slot.end);
679
+ range.deleteContents();
680
+ for (const n of oldChildren) slot.start.parentNode.insertBefore(n, slot.end);
681
+ }
682
+
683
+ /* ====================================================================
684
+ * Diff helper for the webjs-frame escape hatch
685
+ * ==================================================================== */
686
+
687
+ /**
688
+ * Diff children of two elements (used by the webjs-frame swap path).
689
+ *
690
+ * @param {Element} dst
691
+ * @param {Element} src
692
+ */
693
+ export function diffChildren(dst, src) {
694
+ reconcileChildren(dst, src);
695
+ }
696
+
697
+ /* ====================================================================
698
+ * Head merge
699
+ * ==================================================================== */
700
+
701
+ /**
702
+ * Add-only head merge for partial (marker + frame) swaps. Updates the
703
+ * title and adds new elements (modulepreloads, scripts) without
704
+ * removing existing ones: runtime-generated content like Tailwind's
705
+ * injected CSS must survive across navigations that keep the outer
706
+ * layout mounted.
707
+ *
708
+ * @param {HTMLHeadElement} newHead
709
+ */