@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.
Files changed (61) 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 +21 -21
  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/attribute-reader.js +178 -0
  12. package/src/component/lifecycle.js +1481 -0
  13. package/src/component/reactive.js +171 -0
  14. package/src/component/server-element.js +248 -0
  15. package/src/component.d.ts +10 -0
  16. package/src/component.js +2 -1931
  17. package/src/html-entities.js +2179 -0
  18. package/src/render-client/parts.js +2007 -0
  19. package/src/render-client/reconciler.js +388 -0
  20. package/src/render-client/template-compiler.js +525 -0
  21. package/src/render-client.js +1 -2939
  22. package/src/render-server/attrs.js +160 -0
  23. package/src/render-server/dsd.js +445 -0
  24. package/src/render-server/html-scan.js +402 -0
  25. package/src/render-server/slots.js +213 -0
  26. package/src/render-server/stream.js +163 -0
  27. package/src/render-server/template-renderer.js +923 -0
  28. package/src/render-server/text.js +163 -0
  29. package/src/render-server.js +6 -2334
  30. package/src/router-client/anchors.js +44 -0
  31. package/src/router-client/boundaries.js +221 -0
  32. package/src/router-client/constants.js +187 -0
  33. package/src/router-client/diagnostics.js +377 -0
  34. package/src/router-client/dom-differ.js +709 -0
  35. package/src/router-client/dom-parse.js +150 -0
  36. package/src/router-client/events.js +139 -0
  37. package/src/router-client/fetch-apply.js +387 -0
  38. package/src/router-client/form-encoder.js +135 -0
  39. package/src/router-client/frames.js +329 -0
  40. package/src/router-client/head-merge.js +344 -0
  41. package/src/router-client/nav-error.js +105 -0
  42. package/src/router-client/navigator.js +781 -0
  43. package/src/router-client/prefetch.js +824 -0
  44. package/src/router-client/scroll.js +259 -0
  45. package/src/router-client/snapshot-cache.js +97 -0
  46. package/src/router-client/state.js +92 -0
  47. package/src/router-client/stream.js +206 -0
  48. package/src/router-client/swap.js +451 -0
  49. package/src/router-client/upgrade.js +261 -0
  50. package/src/router-client/view-transition.js +223 -0
  51. package/src/router-client.d.ts +10 -1
  52. package/src/router-client.js +75 -5345
  53. package/src/slot/assignment.js +304 -0
  54. package/src/slot/interception.js +558 -0
  55. package/src/slot/polyfills.js +365 -0
  56. package/src/slot/project.js +432 -0
  57. package/src/slot/sensors.js +303 -0
  58. package/src/slot/state.js +245 -0
  59. package/src/slot/symbols.js +85 -0
  60. package/src/slot.js +7 -2199
  61. package/src/webjs-config.d.ts +13 -2
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Client router: frames.
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 { FRAME_TOP } from './constants.js';
10
+ import { warnOnce } from './diagnostics.js';
11
+ import { outerHTMLForDiff } from './head-merge.js';
12
+
13
+ /**
14
+ * Find the id of the innermost <webjs-frame> enclosing `el`, walking up
15
+ * through normal DOM and any shadow boundaries it crosses. Returns null
16
+ * if the element is not inside any frame.
17
+ *
18
+ * @param {Element | null} el
19
+ * @returns {string | null}
20
+ */
21
+ export function activeFrameId(el) {
22
+ /** @type {Element | null} */
23
+ let cur = el;
24
+ while (cur) {
25
+ const frame = cur.closest('webjs-frame');
26
+ if (frame && frame.id) return frame.id;
27
+ // Cross shadow boundary upwards if necessary.
28
+ const root = cur.getRootNode();
29
+ if (root && /** @type any */ (root).host) {
30
+ cur = /** @type any */ (root).host;
31
+ } else {
32
+ break;
33
+ }
34
+ }
35
+ return null;
36
+ }
37
+
38
+ /**
39
+ * Resolve which `<webjs-frame>` (if any) a trigger drives, honoring an
40
+ * explicit `data-webjs-frame` attribute before the closest-enclosing-frame
41
+ * default. Models Turbo's `data-turbo-frame` external targeting:
42
+ *
43
+ * - `data-webjs-frame="<id>"` on (or above) the trigger drives the frame
44
+ * with that id, resolved via `getElementById` in the CURRENT document.
45
+ * This lets an EXTERNAL link / form (a sidebar, a filter form) drive a
46
+ * content frame it is NOT DOM-nested in. If the id does not resolve to a
47
+ * live `<webjs-frame>`, we warn ONCE and fall back to a normal full nav
48
+ * (the fail-safe posture: never throw, never silently swap the wrong
49
+ * region).
50
+ * - `data-webjs-frame="_top"` forces a full-page navigation even when the
51
+ * trigger is inside a frame, returning `null` so the swap escapes to the
52
+ * layout-marker / full-body path.
53
+ * - No `data-webjs-frame` keeps today's behavior: the innermost enclosing
54
+ * frame via `activeFrameId`.
55
+ *
56
+ * Resolution precedence: explicit `data-webjs-frame` > closest enclosing
57
+ * frame. The attribute is read with `closest('[data-webjs-frame]')` so it
58
+ * may live on the trigger itself or any ancestor (e.g. a `<nav>` wrapping a
59
+ * set of links that all target one frame).
60
+ *
61
+ * @param {Element | null} trigger
62
+ * @returns {string | null} A frame id to swap, or null for a full nav.
63
+ */
64
+ export function resolveTargetFrameId(trigger) {
65
+ if (!trigger) return null;
66
+ const carrier = trigger.closest && trigger.closest('[data-webjs-frame]');
67
+ const explicit = carrier
68
+ ? (/** @type {HTMLElement} */ (carrier).dataset
69
+ ? /** @type {HTMLElement} */ (carrier).dataset.webjsFrame
70
+ : carrier.getAttribute('data-webjs-frame'))
71
+ : null;
72
+ if (explicit != null && explicit !== '') {
73
+ if (explicit === FRAME_TOP) {
74
+ // Break out: a full-page nav, never a frame swap.
75
+ return null;
76
+ }
77
+ // External targeting by id. Resolve in the current document.
78
+ const el = typeof document !== 'undefined' ? document.getElementById(explicit) : null;
79
+ if (el && el.tagName && el.tagName.toLowerCase() === 'webjs-frame') {
80
+ return explicit;
81
+ }
82
+ // Unresolvable id: warn once, fall back to a normal full nav so the
83
+ // click still works rather than swapping nothing or the wrong region.
84
+ warnOnce(
85
+ `webjs:frame-unresolved:${explicit}`,
86
+ `[webjs] data-webjs-frame="${explicit}" did not match a live <webjs-frame id="${explicit}">; performing a normal navigation instead.`,
87
+ );
88
+ return null;
89
+ }
90
+ // No explicit target: today's closest-enclosing-frame default.
91
+ return activeFrameId(trigger);
92
+ }
93
+
94
+ /**
95
+ * The live `<webjs-frame id>` element, or null when no frame with that id is
96
+ * in the document. Tag-checked on BOTH lookups, so an unrelated element with
97
+ * a colliding id never passes for a frame.
98
+ *
99
+ * The one definition of "this frame is live", shared by the busy lifecycle and
100
+ * by the prefetch cache's frame-entry validity check (#1407), so the two
101
+ * cannot drift on what liveness means.
102
+ *
103
+ * @param {string | null} frameId
104
+ * @returns {Element | null}
105
+ */
106
+ export function liveFrameElement(frameId) {
107
+ if (!frameId || typeof document === 'undefined') return null;
108
+ let el = null;
109
+ try { el = document.querySelector(`webjs-frame#${CSS.escape(frameId)}`); }
110
+ catch { el = document.getElementById(frameId); }
111
+ if (!el || !el.tagName || el.tagName.toLowerCase() !== 'webjs-frame') return null;
112
+ return el;
113
+ }
114
+
115
+ /**
116
+ * The nav token that currently OWNS each frame's busy state. Under two rapid
117
+ * frame navs the router aborts the first; its `finally` would otherwise clear
118
+ * `aria-busy` that the SECOND nav already re-set, leaving the frame falsely
119
+ * idle while still loading (and an unbalanced busy-event stream). A clear only
120
+ * fires when its token still owns the frame, so the superseding nav's busy
121
+ * state survives the aborted nav's teardown.
122
+ *
123
+ * @type {WeakMap<Element, number>}
124
+ */
125
+ export const frameBusyTokens = new WeakMap();
126
+
127
+ /**
128
+ * Set `aria-busy="true"` on the live `<webjs-frame id>` element and announce
129
+ * the start of its load with a bubbling `webjs:frame-busy` event (detail
130
+ * `{ frameId, busy: true }`), mirroring Turbo's `frame.markAsBusy`. Stamps the
131
+ * nav `token` as the frame's busy owner (see `frameBusyTokens`). Returns the
132
+ * resolved frame element so `clearFrameBusy` can target the SAME node even if
133
+ * the swap later replaces the frame's id lookup (the element identity is stable
134
+ * across a child-only frame swap). Returns null when the frame is not in the
135
+ * live DOM (e.g. a stale external `data-webjs-frame` that slipped the
136
+ * resolve-time check), so nothing to mark.
137
+ *
138
+ * @param {string} frameId
139
+ * @param {number} token
140
+ * @returns {Element | null}
141
+ */
142
+ export function markFrameBusy(frameId, token) {
143
+ const frame = liveFrameElement(frameId);
144
+ if (!frame) return null;
145
+ // Dispatch the `true` edge only on a real idle -> busy transition, so a nav
146
+ // that supersedes an in-flight one (frame already busy) does not emit a
147
+ // redundant `true`. The token always advances to the newest owner.
148
+ const wasBusy = frameBusyTokens.has(frame);
149
+ frameBusyTokens.set(frame, token);
150
+ frame.setAttribute('aria-busy', 'true');
151
+ if (!wasBusy) {
152
+ frame.dispatchEvent(new CustomEvent('webjs:frame-busy', {
153
+ bubbles: true,
154
+ detail: { frameId, busy: true },
155
+ }));
156
+ }
157
+ return frame;
158
+ }
159
+
160
+ /**
161
+ * Clear the busy state set by `markFrameBusy`: set `aria-busy="false"` and
162
+ * dispatch the matching `webjs:frame-busy` (detail `{ frameId, busy: false }`)
163
+ * so app code sees a symmetric start/finish pair. Mirrors Turbo's
164
+ * `frame.clearBusyState`. Operates on the element captured at start, so an
165
+ * abort / error clears the same node the start marked. A clear whose token no
166
+ * longer owns the frame (a newer nav re-set busy) is a stale teardown from a
167
+ * superseded nav and is skipped, so the live nav stays busy.
168
+ *
169
+ * @param {Element} frame
170
+ * @param {number} token
171
+ */
172
+ export function clearFrameBusy(frame, token) {
173
+ if (frameBusyTokens.get(frame) !== token) return;
174
+ frameBusyTokens.delete(frame);
175
+ frame.setAttribute('aria-busy', 'false');
176
+ const frameId = frame.id || null;
177
+ frame.dispatchEvent(new CustomEvent('webjs:frame-busy', {
178
+ bubbles: true,
179
+ detail: { frameId, busy: false },
180
+ }));
181
+ }
182
+
183
+ /**
184
+ * The nav token that currently OWNS each form's submission-busy state. Same
185
+ * role as `frameBusyTokens` for frames: under two rapid submits the router
186
+ * aborts the first, and its `finally` would otherwise clear `aria-busy` /
187
+ * dispatch `webjs:submit-end` for a submission the SECOND submit already
188
+ * re-set, leaving the form falsely idle while still submitting (and an
189
+ * unbalanced start/end event stream). A clear only fires when its token still
190
+ * owns the form, so the superseding submit's busy state survives the aborted
191
+ * submit's teardown.
192
+ *
193
+ * @type {WeakMap<Element, number>}
194
+ */
195
+ export const formBusyTokens = new WeakMap();
196
+
197
+ /**
198
+ * Mark a submitting `<form>` busy: set the native `aria-busy="true"` (the
199
+ * readable "is this form submitting" primitive any component can poll) and
200
+ * dispatch a bubbling `webjs:submit-start` event (detail `{ form, url }`).
201
+ * Stamps `token` as the form's busy owner (see `formBusyTokens`). The `true`
202
+ * edge fires only on a real idle -> busy transition, so a submit that
203
+ * supersedes an in-flight one (form already busy) does not emit a redundant
204
+ * start; the token always advances to the newest owner. Returns the form so
205
+ * `clearFormBusy` targets the same node.
206
+ *
207
+ * @param {HTMLFormElement} form
208
+ * @param {number} token
209
+ * @param {string} url Resolved action URL the submission targets.
210
+ * @returns {HTMLFormElement}
211
+ */
212
+ export function markFormBusy(form, token, url) {
213
+ const wasBusy = formBusyTokens.has(form);
214
+ formBusyTokens.set(form, token);
215
+ form.setAttribute('aria-busy', 'true');
216
+ if (!wasBusy) {
217
+ form.dispatchEvent(new CustomEvent('webjs:submit-start', {
218
+ bubbles: true,
219
+ detail: { form, url },
220
+ }));
221
+ }
222
+ return form;
223
+ }
224
+
225
+ /**
226
+ * Clear the busy state set by `markFormBusy`: set `aria-busy="false"` and
227
+ * dispatch the matching `webjs:submit-end` (detail `{ form, url, ok }`, `ok` =
228
+ * the submission settled as a success / not an error outcome) so app code sees
229
+ * a symmetric start/finish pair. Operates on the element captured at start, so
230
+ * an abort / error clears the same node the start marked. A clear whose token
231
+ * no longer owns the form (a newer submit re-set busy) is a stale teardown
232
+ * from a superseded submit and is skipped, so the live submit stays busy.
233
+ *
234
+ * @param {HTMLFormElement} form
235
+ * @param {number} token
236
+ * @param {string} url
237
+ * @param {boolean} ok
238
+ */
239
+ export function clearFormBusy(form, token, url, ok) {
240
+ if (formBusyTokens.get(form) !== token) return;
241
+ formBusyTokens.delete(form);
242
+ form.setAttribute('aria-busy', 'false');
243
+ const evt = new CustomEvent('webjs:submit-end', {
244
+ bubbles: true,
245
+ detail: { form, url, ok: !!ok },
246
+ });
247
+ // A successful submission swaps the page in place, and a full-body swap
248
+ // (or a swap whose region contained the form) detaches the form before
249
+ // this teardown runs. A bubbling event dispatched on a DISCONNECTED node
250
+ // never reaches a `document`-level listener, so a synchronous swap (the
251
+ // no-view-transition default) would silently drop `submit-end`. Dispatch
252
+ // on `document` when the form is no longer connected so the symmetric
253
+ // start/end pair always lands, regardless of swap timing.
254
+ if (form.isConnected) {
255
+ form.dispatchEvent(evt);
256
+ } else if (typeof document !== 'undefined') {
257
+ document.dispatchEvent(evt);
258
+ } else {
259
+ form.dispatchEvent(evt);
260
+ }
261
+ }
262
+
263
+ /**
264
+ * Apply the swap from a parsed incoming Document onto the live document.
265
+ * Picks the most-scoped match: explicit webjs-frame > deepest shared
266
+ * layout marker > full body swap.
267
+ *
268
+ * If the incoming page carries a different importmap from the current
269
+ * page (typical after a deploy that bumped a vendor pin), partial swap
270
+ * is unsafe: importmaps are immutable once applied, so the new page
271
+ * would resolve modules against the stale URLs. We fall back to a full
272
+ * page load via `location.assign(href)`. Mirrors Turbo's
273
+ * `tracked_element_mismatch` reload, applied specifically to
274
+ * importmaps. Called with `href = null` for revalidation flows (which
275
+ * never trigger a hard reload).
276
+ *
277
+ * Detection uses the `X-Webjs-Build` response header (read by the
278
+ * fetch path and passed in as `incomingBuild`), compared against the
279
+ * current page's `data-webjs-build`. The header is set on EVERY SSR
280
+ * response, including X-Webjs-Have partial responses that omit the
281
+ * head and importmap entirely, and it carries the PUBLISHED build id,
282
+ * which the server advertises only once the importmap is final. A hard
283
+ * reload fires only when both ids are present and differ (a real
284
+ * cross-deploy). An empty / absent id on either side means "version
285
+ * unknown" (a warming runtime-first-boot server, or a response that
286
+ * predates the header) and never triggers a reload, so the warmup
287
+ * window cannot wipe a half-filled form.
288
+ *
289
+ * @param {Document} doc
290
+ * @param {string | null} frameId
291
+ * @param {boolean} revalidating Restore from cache; already-matched markers may stomp inflight state, signal helps loading templates skip.
292
+ * @param {string | null} [href] Target URL for hard-reload fallback on importmap mismatch.
293
+ * @param {string | null} [incomingBuild] X-Webjs-Build header from the response, or null.
294
+ */
295
+ /**
296
+ * Compute the signature of all `data-webjs-track="reload"` elements
297
+ * in the head of `root`. Returns the concatenation of each element's
298
+ * `outerHTML`, in document order. Two documents with identical
299
+ * tracked-element sets produce identical signatures; any change in
300
+ * attributes, content, or set membership produces a different one.
301
+ *
302
+ * Mirrors hotwired/turbo's `head_snapshot.js` `trackedElementSignature`
303
+ * (the data-turbo-track="reload" mechanism). Used by applySwap as a
304
+ * generic opt-in next to the importmap-specific build hash.
305
+ *
306
+ * Returns the empty string when `root` has no head (e.g. an
307
+ * X-Webjs-Have partial response) or when no elements opt in.
308
+ *
309
+ * @param {Document | undefined} root
310
+ * @returns {string}
311
+ */
312
+ export function trackedReloadSignature(root) {
313
+ if (!root || !root.head) return '';
314
+ const tracked = root.head.querySelectorAll('[data-webjs-track="reload"]');
315
+ if (!tracked.length) return '';
316
+ // Use outerHTMLForDiff so the CSP nonce (which rotates per
317
+ // request) is stripped before signature comparison. Without this,
318
+ // a nonced tracked script like `<script nonce="${cspNonce()}"
319
+ // data-webjs-track="reload" src="/build.js?v=42">` would mismatch
320
+ // every navigation and infinite-reload. Matches Turbo's
321
+ // head_snapshot.js elementWithoutNonce posture.
322
+ let sig = '';
323
+ for (const el of tracked) sig += outerHTMLForDiff(el);
324
+ return sig;
325
+ }
326
+
327
+ /* ====================================================================
328
+ * View Transitions (opt-in) + permanent-element persistence
329
+ * ==================================================================== */
@@ -0,0 +1,344 @@
1
+ /**
2
+ * Client router: head-merge.
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 { META_KEY_CSP_NONCE } from './constants.js';
10
+
11
+ /**
12
+ * Read the CSP nonce that the original page load published via
13
+ * `<meta name="csp-nonce" content="...">`. Returns empty string when
14
+ * no meta tag is present (apps without strict CSP).
15
+ *
16
+ * The meta tag is the contract: server emits it once at SSR time,
17
+ * client reads it for every dynamically-created script. The browser
18
+ * enforces CSP against the nonce the original page declared, NOT the
19
+ * per-request nonce on subsequent navigations. So we always apply
20
+ * THIS nonce, not the source-page nonce that arrived with the new
21
+ * head fragment.
22
+ *
23
+ * Mirrors hotwired/turbo's `getCspNonce` in src/util.js. Not cached:
24
+ * a single querySelector on document.head is cheap, and caching
25
+ * would break if the user (or a test) inserted the meta tag late.
26
+ *
27
+ * @returns {string}
28
+ */
29
+ export function getCspNonce() {
30
+ if (typeof document === 'undefined') return '';
31
+ const meta = document.querySelector('meta[name="csp-nonce"]');
32
+ // Read the `content` attribute, not the `.nonce` IDL property.
33
+ // Turbo's getCspNonce in src/util.js falls back to `.nonce` first
34
+ // because it can be called against script/link elements (where
35
+ // browsers DO expose `.nonce` and additionally clear the
36
+ // `nonce` attribute on document load). The `<meta name="csp-nonce">`
37
+ // element WebJs targets has no `.nonce` IDL (only script + link
38
+ // elements do per HTML spec), so the only viable source is the
39
+ // `content` attribute.
40
+ return meta ? meta.getAttribute('content') || '' : '';
41
+ }
42
+
43
+ /**
44
+ * Create a `<script>` clone of `source` that's safe to insert into the
45
+ * live document under strict CSP. Copies every attribute EXCEPT
46
+ * nonce (the source's nonce is from the new page's per-request token,
47
+ * which the browser's CSP cache from the original page load will
48
+ * reject), then applies the cached nonce from the meta tag. Re-emits
49
+ * textContent so inline scripts execute as if first-loaded.
50
+ *
51
+ * @param {HTMLScriptElement} source
52
+ * @returns {HTMLScriptElement}
53
+ */
54
+ export function cloneScriptWithCorrectNonce(source) {
55
+ const script = document.createElement('script');
56
+ for (const attr of source.attributes) {
57
+ if (attr.name === 'nonce') continue;
58
+ script.setAttribute(attr.name, attr.value);
59
+ }
60
+ const nonce = getCspNonce();
61
+ if (nonce) {
62
+ // Use setAttribute so the attribute is queryable
63
+ // (`getAttribute('nonce')`, outerHTML serialization, etc.).
64
+ // Per CSP3 the .nonce IDL property is the authoritative source
65
+ // for the CSP check, but real browsers reflect setAttribute into
66
+ // .nonce automatically. Test environments (linkedom) reflect only
67
+ // one direction, so we set the attribute.
68
+ script.setAttribute('nonce', nonce);
69
+ }
70
+ script.textContent = source.textContent;
71
+ return script;
72
+ }
73
+
74
+ /**
75
+ * Clone any head element while substituting the page-load CSP nonce
76
+ * for the source's per-request nonce. Used for `<link rel="modulepreload"
77
+ * nonce="...">` and any other nonce-carrying head element: browsers
78
+ * gate cross-origin module preload by script-src nonce too, so the
79
+ * per-request nonce from the new page's head would be blocked by the
80
+ * browser's CSP cache from the original page load.
81
+ *
82
+ * Returns a cloneNode(true) for elements without a nonce attribute,
83
+ * so non-CSP cases stay zero-cost.
84
+ *
85
+ * @param {Element} source
86
+ * @returns {Element}
87
+ */
88
+ function cloneElementWithCorrectNonce(source) {
89
+ if (!source.hasAttribute('nonce')) return source.cloneNode(true);
90
+ const clone = /** @type {Element} */ (source.cloneNode(true));
91
+ const nonce = getCspNonce();
92
+ if (nonce) {
93
+ clone.setAttribute('nonce', nonce);
94
+ } else {
95
+ clone.removeAttribute('nonce');
96
+ }
97
+ return clone;
98
+ }
99
+
100
+ /**
101
+ * Return an `outerHTML` string suitable for head-diff comparison: strip
102
+ * any nonce attribute so per-request nonces don't cause every script in
103
+ * the head to look "changed" on every navigation. The original element
104
+ * is left untouched (we clone first).
105
+ *
106
+ * Mirrors hotwired/turbo's `elementWithoutNonce` pattern in
107
+ * src/core/drive/head_snapshot.js.
108
+ *
109
+ * @param {Element} el
110
+ * @returns {string}
111
+ */
112
+ export function outerHTMLForDiff(el) {
113
+ // Strip nonce from ANY element type. SCRIPT obviously, but also LINK
114
+ // (modulepreload tags carry nonce per the recent CSP fix). Without
115
+ // this, per-request nonces on link tags would cause the diff to
116
+ // treat every preload as "changed", duplicating preloads on every
117
+ // navigation.
118
+ if (!el.hasAttribute('nonce')) return el.outerHTML;
119
+ const clone = /** @type {Element} */ (el.cloneNode(true));
120
+ clone.removeAttribute('nonce');
121
+ return clone.outerHTML;
122
+ }
123
+
124
+ /**
125
+ * Stable identity key for a `<meta>` that represents a single logical tag, so a
126
+ * PAGE-SCOPED meta can be reconciled across a soft-nav head merge (#1046). A
127
+ * meta with no identifying attribute returns null and is left to the add-only
128
+ * path (added but never removed), since its identity is ambiguous.
129
+ *
130
+ * @param {Element} m
131
+ * @returns {string | null}
132
+ */
133
+ function metaIdentity(m) {
134
+ const name = m.getAttribute('name');
135
+ if (name) return 'name=' + name;
136
+ const property = m.getAttribute('property');
137
+ if (property) return 'property=' + property;
138
+ const httpEquiv = m.getAttribute('http-equiv');
139
+ if (httpEquiv) return 'http-equiv=' + httpEquiv;
140
+ if (m.hasAttribute('charset')) return 'charset';
141
+ return null;
142
+ }
143
+
144
+ /**
145
+ * Reconcile keyed `<meta>` tags across a soft-nav head merge (#1046). The
146
+ * add-only merge (`addNewHeadElements`) never removes a stale head element, so a
147
+ * PAGE-SCOPED meta the previous page added (a `view-transition` opt-in, a
148
+ * per-page `robots` / `theme-color` / `description`, an `og:*` property) leaked
149
+ * onto every later page. This pass gives each keyed meta the full add / update /
150
+ * remove treatment: a meta present in the incoming head is added or synced, and
151
+ * a live keyed meta ABSENT from the incoming head is removed.
152
+ *
153
+ * Safe against the `X-Webjs-Have` reduced-head optimization (#936): that
154
+ * optimization only omits the shared app STYLESHEET (already on the client), and
155
+ * this pass touches ONLY `<meta>` tags, never a stylesheet / link / script. The
156
+ * incoming head always carries the target page's complete meta set (charset,
157
+ * viewport, and the app-wide metas from the root layout appear in both heads, so
158
+ * they are preserved), so "absent from the incoming head" means "this page does
159
+ * not declare it", not "optimized away".
160
+ *
161
+ * A key may repeat (multiple `og:image`), so both sides are grouped into a LIST
162
+ * per key and reconciled as a set: an unchanged set is left alone, else the live
163
+ * copies are removed and the incoming set re-appended.
164
+ *
165
+ * @param {HTMLHeadElement} newHead
166
+ */
167
+ function reconcileHeadMetas(newHead) {
168
+ // A HEADLESS fragment response (a `<webjs-frame>` subtree) has no `<head>`, so
169
+ // `parseHTML` leaves `newHead` empty. A real full head ALWAYS emits charset +
170
+ // viewport, so "no `<meta>` at all in the incoming head" means "this is a
171
+ // fragment, not a head to reconcile against". Skipping it here is what keeps a
172
+ // frame swap from stripping every live page-scoped meta (viewport, og:*, ...).
173
+ if (!newHead.querySelector('meta')) return;
174
+
175
+ /** @param {ParentNode} root @returns {Map<string, Element[]>} */
176
+ const group = (root) => {
177
+ const map = new Map();
178
+ for (const el of root.querySelectorAll('meta')) {
179
+ const key = metaIdentity(el);
180
+ if (!key || key === META_KEY_CSP_NONCE) continue;
181
+ const list = map.get(key);
182
+ if (list) list.push(el); else map.set(key, [el]);
183
+ }
184
+ return map;
185
+ };
186
+ const incoming = group(newHead);
187
+ const live = group(document.head);
188
+
189
+ // Add or replace each incoming key whose SET differs from the live set.
190
+ for (const [key, incEls] of incoming) {
191
+ const liveEls = live.get(key) || [];
192
+ const incKey = incEls.map(outerHTMLForDiff).join('\n');
193
+ const liveKey = liveEls.map(outerHTMLForDiff).join('\n');
194
+ if (incKey === liveKey) continue;
195
+ if (incEls.length === 1 && liveEls.length === 1) {
196
+ // The common case (one description / theme-color / robots per page):
197
+ // sync attributes IN PLACE so the live element keeps its DOM identity.
198
+ // An app script holding a reference (a theme manager caching
199
+ // meta[name=theme-color]) still points at the live tag after the nav,
200
+ // and there is no remove/append churn for a content-only change.
201
+ const cur = liveEls[0];
202
+ for (const a of [...cur.attributes]) cur.removeAttribute(a.name);
203
+ for (const a of incEls[0].attributes) cur.setAttribute(a.name, a.value);
204
+ continue;
205
+ }
206
+ // Multi-element sets (repeated og:image): no unambiguous element-to-element
207
+ // mapping exists, so replace the set wholesale.
208
+ for (const el of liveEls) el.remove();
209
+ for (const el of incEls) document.head.appendChild(cloneElementWithCorrectNonce(el));
210
+ }
211
+ // Remove a stale page-scoped key the incoming page does not declare at all.
212
+ for (const [key, liveEls] of live) {
213
+ if (!incoming.has(key)) for (const el of liveEls) el.remove();
214
+ }
215
+ }
216
+
217
+ export function addNewHeadElements(newHead) {
218
+ const newTitle = newHead.querySelector('title');
219
+ if (newTitle) document.title = newTitle.textContent || '';
220
+
221
+ const currentSet = new Set();
222
+ for (const el of document.head.children) currentSet.add(outerHTMLForDiff(el));
223
+
224
+ for (const el of newHead.children) {
225
+ if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') {
226
+ // Skip: partial swaps keep the outer layout mounted, so the
227
+ // existing importmap stays authoritative. Importmaps are
228
+ // immutable once a script has run (modern browsers ignore
229
+ // subsequent `<script type=importmap>`). Importmap-mismatch
230
+ // detection lives at the applySwap entry: a mismatch there
231
+ // triggers a full reload before we ever reach this loop.
232
+ continue;
233
+ }
234
+ if (el.tagName === 'BASE') continue;
235
+ if (el.tagName === 'TITLE') continue;
236
+ // A keyed <meta> is add/update/remove reconciled below (#1046), so skip it
237
+ // here to avoid appending a duplicate when its content changed.
238
+ if (el.tagName === 'META' && metaIdentity(el)) continue;
239
+ if (!currentSet.has(outerHTMLForDiff(el))) {
240
+ if (el.tagName === 'SCRIPT') {
241
+ document.head.appendChild(
242
+ cloneScriptWithCorrectNonce(/** @type {HTMLScriptElement} */ (el)),
243
+ );
244
+ } else {
245
+ document.head.appendChild(cloneElementWithCorrectNonce(el));
246
+ }
247
+ }
248
+ }
249
+
250
+ // Reconcile keyed <meta> tags so a stale page-scoped meta is removed, not
251
+ // leaked onto every later page (#1046).
252
+ reconcileHeadMetas(newHead);
253
+ }
254
+
255
+ /**
256
+ * Is `el` a stylesheet the head merge must never remove: a `<style>` or a
257
+ * `<link rel~="stylesheet">`. WebJs ALWAYS keeps these on a soft nav, with no
258
+ * opt-out, and that is a deliberate divergence from Turbo. Turbo removes a
259
+ * stylesheet absent from the new head when it is tagged
260
+ * `data-turbo-track="dynamic"`, which is sound in Turbo because a Turbo visit
261
+ * always compares a COMPLETE old head to a COMPLETE new head, so "absent" means
262
+ * "this page removed it". WebJs's `X-Webjs-Have` optimization returns a REDUCED
263
+ * head (the shared app stylesheet is omitted because the client already has it),
264
+ * so "absent from the incoming head" means "optimized away", NOT "removed". A
265
+ * dynamic-removal opt-out would therefore re-introduce #936 (it would strip a
266
+ * still-needed sheet on any partial response), and WebJs is Tailwind-first (one
267
+ * global sheet, no page-specific sheets to drop), so the knob would be unsafe
268
+ * and unused. Keeping every stylesheet is correct here; a genuinely changed one
269
+ * is dropped by the deploy-level hard reload (build-id mismatch), not a soft swap.
270
+ *
271
+ * @param {Element} el
272
+ * @returns {boolean}
273
+ */
274
+ function isPersistentHeadStyle(el) {
275
+ if (el.tagName === 'STYLE') return true;
276
+ return el.tagName === 'LINK' &&
277
+ (el.getAttribute('rel') || '').toLowerCase().split(/\s+/).includes('stylesheet');
278
+ }
279
+
280
+ /** @param {HTMLHeadElement} newHead */
281
+ export function mergeHead(newHead) {
282
+ const currentHead = document.head;
283
+
284
+ const newTitle = newHead.querySelector('title');
285
+ if (newTitle) document.title = newTitle.textContent || '';
286
+
287
+ const currentSet = new Set();
288
+ for (const el of currentHead.children) {
289
+ if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') continue;
290
+ if (el.tagName === 'BASE') continue;
291
+ currentSet.add(outerHTMLForDiff(el));
292
+ }
293
+
294
+ const newSet = new Set();
295
+ for (const el of newHead.children) {
296
+ if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') continue;
297
+ if (el.tagName === 'BASE') continue;
298
+ newSet.add(outerHTMLForDiff(el));
299
+ }
300
+
301
+ for (const el of [...currentHead.children]) {
302
+ if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') continue;
303
+ if (el.tagName === 'BASE') continue;
304
+ if (el.tagName === 'TITLE') continue;
305
+ // #936: NEVER remove a stylesheet or a `<style>` on a soft nav (Turbo's
306
+ // persistent-CSS model). The incoming head of a full-body-swap fallback can
307
+ // legitimately lack the app's `<link rel=stylesheet>` (a partial or mangled
308
+ // response, e.g. from a mid-parse empty-`have` prefetch): removing the live
309
+ // one there leaves the whole page unstyled until a manual refresh, the
310
+ // headline #936 symptom. Keeping it is safe: a genuinely stale sheet is
311
+ // dropped by the deploy-level hard reload (build-id mismatch), not here.
312
+ if (isPersistentHeadStyle(el)) continue;
313
+ // Never remove the CSP nonce meta: the incoming full-body response carries a
314
+ // FRESH per-request nonce, but the browser enforces CSP against the nonce the
315
+ // ORIGINAL page load declared (see `getCspNonce`), so the live one must stay
316
+ // (#1050). `outerHTMLForDiff` strips the nonce ATTRIBUTE but not the `content`
317
+ // it lives in on this meta, so without this it looks "changed" and is dropped.
318
+ if (el.tagName === 'META' && metaIdentity(el) === META_KEY_CSP_NONCE) continue;
319
+ if (!newSet.has(outerHTMLForDiff(el))) el.remove();
320
+ }
321
+
322
+ for (const el of newHead.children) {
323
+ if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') continue;
324
+ if (el.tagName === 'BASE') continue;
325
+ if (el.tagName === 'TITLE') continue;
326
+ // Do not append the incoming per-request csp-nonce meta (the live original is
327
+ // kept above), or the head would carry two and `getCspNonce` could read the
328
+ // wrong one (#1050).
329
+ if (el.tagName === 'META' && metaIdentity(el) === META_KEY_CSP_NONCE) continue;
330
+ if (!currentSet.has(outerHTMLForDiff(el))) {
331
+ if (el.tagName === 'SCRIPT') {
332
+ currentHead.appendChild(
333
+ cloneScriptWithCorrectNonce(/** @type {HTMLScriptElement} */ (el)),
334
+ );
335
+ } else {
336
+ currentHead.appendChild(cloneElementWithCorrectNonce(el));
337
+ }
338
+ }
339
+ }
340
+ }
341
+
342
+ /* ====================================================================
343
+ * Custom-element upgrade + script reactivation
344
+ * ==================================================================== */