@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,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
|
+
* ==================================================================== */
|