@webjsdev/core 0.7.50 → 0.7.53
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 +20 -20
- 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 +3 -3
- package/index.js +1 -1
- package/package.json +1 -1
- 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.js +2 -1896
- package/src/css.d.ts +9 -0
- package/src/escape.d.ts +2 -0
- package/src/html.d.ts +9 -0
- package/src/render-client/parts.js +2039 -0
- package/src/render-client/reconciler.js +388 -0
- package/src/render-client/template-compiler.js +525 -0
- package/src/render-client.js +1 -2938
- 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 +938 -0
- package/src/render-server/text.js +163 -0
- package/src/render-server.js +6 -2405
- package/src/repeat.d.ts +15 -0
- package/src/rich-fetch.d.ts +4 -0
- package/src/router-client/anchors.js +44 -0
- package/src/router-client/boundaries.js +221 -0
- package/src/router-client/constants.js +206 -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 +182 -0
- package/src/router-client/fetch-apply.js +432 -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 +952 -0
- package/src/router-client/prefetch.js +831 -0
- package/src/router-client/scroll.js +308 -0
- package/src/router-client/snapshot-cache.js +113 -0
- package/src/router-client/state.js +155 -0
- package/src/router-client/stream.js +206 -0
- package/src/router-client/swap.js +454 -0
- package/src/router-client/upgrade.js +261 -0
- package/src/router-client/view-transition.js +223 -0
- package/src/router-client.d.ts +11 -2
- package/src/router-client.js +78 -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/suspense.d.ts +9 -0
- package/src/websocket-client.d.ts +22 -0
|
@@ -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
|
+
*/
|