@webjsdev/core 0.7.1 → 0.7.3
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/README.md +34 -2
- package/dist/webjs-core-browser.js +4 -0
- package/dist/webjs-core-browser.js.map +6 -0
- package/dist/webjs-core-lazy-loader.js +2 -0
- package/dist/webjs-core-lazy-loader.js.map +6 -0
- package/dist/webjs-core-testing.js +3 -0
- package/dist/webjs-core-testing.js.map +6 -0
- package/dist/webjs-core.js +4 -0
- package/dist/webjs-core.js.map +6 -0
- package/index-browser.js +58 -0
- package/index.js +1 -0
- package/package.json +41 -7
- package/src/component.js +8 -0
- package/src/csp-nonce.js +50 -0
- package/src/registry.js +1 -1
- package/src/render-server.js +10 -1
- package/src/router-client.js +333 -46
package/src/router-client.js
CHANGED
|
@@ -150,8 +150,22 @@ let prevScrollRestoration = null;
|
|
|
150
150
|
export function enableClientRouter() {
|
|
151
151
|
if (enabled || typeof document === 'undefined') return;
|
|
152
152
|
enabled = true;
|
|
153
|
-
|
|
154
|
-
|
|
153
|
+
// Both `click` and `submit` are BUBBLE phase, not capture. A component's
|
|
154
|
+
// per-element `@click` / `@submit` handler (render-client.js) runs in the
|
|
155
|
+
// at-target phase, BEFORE a document-level bubble listener. So onClick /
|
|
156
|
+
// onSubmit run AFTER the component, and their `if (e.defaultPrevented) return`
|
|
157
|
+
// guard sees the component's `preventDefault` and leaves the element alone.
|
|
158
|
+
// A capture listener would run FIRST, before the component, so the guard
|
|
159
|
+
// would always see `false` and the router would wrongly hijack a JS-handled
|
|
160
|
+
// link or form: navigate a `<a @click=${e => e.preventDefault()}>` away, or
|
|
161
|
+
// submit a `<form @submit=${e => e.preventDefault()}>` (the live chat /
|
|
162
|
+
// comments forms, which preventDefault and send over WebSocket / fetch),
|
|
163
|
+
// navigating the page out from under it. All the phase-independent filtering
|
|
164
|
+
// (modifier / middle clicks, downloads, cross-origin, hash links, GET-vs-POST)
|
|
165
|
+
// happens inside onClick / onSubmit regardless of phase. Mirrors
|
|
166
|
+
// hotwired/turbo, which does its interception work in bubble listeners.
|
|
167
|
+
document.addEventListener('click', onClick, false);
|
|
168
|
+
document.addEventListener('submit', onSubmit, false);
|
|
155
169
|
window.addEventListener('popstate', onPopState);
|
|
156
170
|
ensureUpgradeObserver();
|
|
157
171
|
// Take control of scroll restoration so the browser doesn't fight
|
|
@@ -169,8 +183,8 @@ export function enableClientRouter() {
|
|
|
169
183
|
export function disableClientRouter() {
|
|
170
184
|
if (!enabled) return;
|
|
171
185
|
enabled = false;
|
|
172
|
-
document.removeEventListener('click', onClick,
|
|
173
|
-
document.removeEventListener('submit', onSubmit,
|
|
186
|
+
document.removeEventListener('click', onClick, false);
|
|
187
|
+
document.removeEventListener('submit', onSubmit, false);
|
|
174
188
|
window.removeEventListener('popstate', onPopState);
|
|
175
189
|
if (typeof history !== 'undefined' && prevScrollRestoration !== null) {
|
|
176
190
|
history.scrollRestoration = prevScrollRestoration;
|
|
@@ -257,11 +271,14 @@ function onPopState(_e) {
|
|
|
257
271
|
}
|
|
258
272
|
|
|
259
273
|
/**
|
|
260
|
-
* Intercept form submissions.
|
|
261
|
-
* `@submit`
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
274
|
+
* Intercept form submissions. BUBBLE phase (see enableClientRouter) so we run
|
|
275
|
+
* AFTER a component's per-element `@submit` handler, which is bound at-target.
|
|
276
|
+
* That ordering is what makes the `if (e.defaultPrevented) return` guard below
|
|
277
|
+
* work: a component that calls `e.preventDefault()` (the chat / comments forms,
|
|
278
|
+
* or any JS-handled form) has already run, so we see the prevented default and
|
|
279
|
+
* leave the form alone. A capture listener would fire us first, before the
|
|
280
|
+
* component, defeating the guard and wrongly navigating the page out from under
|
|
281
|
+
* a JS-handled form.
|
|
265
282
|
*
|
|
266
283
|
* Filtering mirrors Turbo's `form_submit_observer.js`:
|
|
267
284
|
* - `data-no-router` on form or submitter → full browser submit.
|
|
@@ -596,7 +613,7 @@ async function performNavigation(href, isPopState, frameId) {
|
|
|
596
613
|
if (cached) {
|
|
597
614
|
const cachedDoc = parseHTML(cached.html);
|
|
598
615
|
if (cachedDoc) {
|
|
599
|
-
applySwap(cachedDoc, frameId, /* revalidating */ true);
|
|
616
|
+
applySwap(cachedDoc, frameId, /* revalidating */ true, /* href */ null);
|
|
600
617
|
// Restore window scroll to where the user left it.
|
|
601
618
|
if (typeof window !== 'undefined') {
|
|
602
619
|
window.scrollTo(cached.scrollX, cached.scrollY);
|
|
@@ -734,6 +751,7 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
|
|
|
734
751
|
method = method || 'GET';
|
|
735
752
|
const myToken = typeof token === 'number' ? token : currentNavigationToken;
|
|
736
753
|
let html;
|
|
754
|
+
let incomingBuild = null;
|
|
737
755
|
/** @type {string} */
|
|
738
756
|
let finalUrl = href;
|
|
739
757
|
try {
|
|
@@ -782,6 +800,12 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
|
|
|
782
800
|
// (turbo/src/core/drive/navigator.js:92-107). Critical for the
|
|
783
801
|
// standard server-rendered validation pattern: 422 + re-rendered
|
|
784
802
|
// form with errors keeps the user's typed input and shows context.
|
|
803
|
+
// Capture the server's build hash header BEFORE reading the body.
|
|
804
|
+
// The header is set on every SSR response, including X-Webjs-Have
|
|
805
|
+
// partial responses where the body has no head and no importmap
|
|
806
|
+
// tag to compare. The applySwap importmap-mismatch guard reads
|
|
807
|
+
// this to detect deploys that bumped the vendor pin.
|
|
808
|
+
incomingBuild = resp.headers.get('x-webjs-build');
|
|
785
809
|
html = await resp.text();
|
|
786
810
|
} catch (err) {
|
|
787
811
|
// Aborted by a newer navigation: let it run, don't fall back.
|
|
@@ -801,7 +825,7 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
|
|
|
801
825
|
const doc = parseHTML(html);
|
|
802
826
|
if (!doc) { location.href = href; return; }
|
|
803
827
|
|
|
804
|
-
applySwap(doc, frameId, false);
|
|
828
|
+
applySwap(doc, frameId, false, finalUrl, incomingBuild);
|
|
805
829
|
|
|
806
830
|
if (recordHistory) history.pushState(null, '', finalUrl);
|
|
807
831
|
|
|
@@ -834,11 +858,173 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
|
|
|
834
858
|
* Picks the most-scoped match: explicit webjs-frame > deepest shared
|
|
835
859
|
* layout marker > full body swap.
|
|
836
860
|
*
|
|
861
|
+
* If the incoming page carries a different importmap from the current
|
|
862
|
+
* page (typical after a deploy that bumped a vendor pin), partial swap
|
|
863
|
+
* is unsafe: importmaps are immutable once applied, so the new page
|
|
864
|
+
* would resolve modules against the stale URLs. We fall back to a full
|
|
865
|
+
* page load via `location.assign(href)`. Mirrors Turbo's
|
|
866
|
+
* `tracked_element_mismatch` reload, applied specifically to
|
|
867
|
+
* importmaps. Called with `href = null` for revalidation flows (which
|
|
868
|
+
* never trigger a hard reload).
|
|
869
|
+
*
|
|
870
|
+
* Detection uses the `X-Webjs-Build` response header (read by the
|
|
871
|
+
* fetch path and passed in as `incomingBuild`), compared against the
|
|
872
|
+
* current page's `data-webjs-build`. The header is set on EVERY SSR
|
|
873
|
+
* response, including X-Webjs-Have partial responses that omit the
|
|
874
|
+
* head and importmap entirely, and it carries the PUBLISHED build id,
|
|
875
|
+
* which the server advertises only once the importmap is final. A hard
|
|
876
|
+
* reload fires only when both ids are present and differ (a real
|
|
877
|
+
* cross-deploy). An empty / absent id on either side means "version
|
|
878
|
+
* unknown" (a warming runtime-first-boot server, or a response that
|
|
879
|
+
* predates the header) and never triggers a reload, so the warmup
|
|
880
|
+
* window cannot wipe a half-filled form.
|
|
881
|
+
*
|
|
837
882
|
* @param {Document} doc
|
|
838
883
|
* @param {string | null} frameId
|
|
839
|
-
* @param {boolean} revalidating Restore from cache
|
|
884
|
+
* @param {boolean} revalidating Restore from cache; already-matched markers may stomp inflight state, signal helps loading templates skip.
|
|
885
|
+
* @param {string | null} [href] Target URL for hard-reload fallback on importmap mismatch.
|
|
886
|
+
* @param {string | null} [incomingBuild] X-Webjs-Build header from the response, or null.
|
|
887
|
+
*/
|
|
888
|
+
/**
|
|
889
|
+
* Compute the signature of all `data-webjs-track="reload"` elements
|
|
890
|
+
* in the head of `root`. Returns the concatenation of each element's
|
|
891
|
+
* `outerHTML`, in document order. Two documents with identical
|
|
892
|
+
* tracked-element sets produce identical signatures; any change in
|
|
893
|
+
* attributes, content, or set membership produces a different one.
|
|
894
|
+
*
|
|
895
|
+
* Mirrors hotwired/turbo's `head_snapshot.js` `trackedElementSignature`
|
|
896
|
+
* (the data-turbo-track="reload" mechanism). Used by applySwap as a
|
|
897
|
+
* generic opt-in next to the importmap-specific build hash.
|
|
898
|
+
*
|
|
899
|
+
* Returns the empty string when `root` has no head (e.g. an
|
|
900
|
+
* X-Webjs-Have partial response) or when no elements opt in.
|
|
901
|
+
*
|
|
902
|
+
* @param {Document | undefined} root
|
|
903
|
+
* @returns {string}
|
|
840
904
|
*/
|
|
841
|
-
function
|
|
905
|
+
function trackedReloadSignature(root) {
|
|
906
|
+
if (!root || !root.head) return '';
|
|
907
|
+
const tracked = root.head.querySelectorAll('[data-webjs-track="reload"]');
|
|
908
|
+
if (!tracked.length) return '';
|
|
909
|
+
// Use outerHTMLForDiff so the CSP nonce (which rotates per
|
|
910
|
+
// request) is stripped before signature comparison. Without this,
|
|
911
|
+
// a nonced tracked script like `<script nonce="${cspNonce()}"
|
|
912
|
+
// data-webjs-track="reload" src="/build.js?v=42">` would mismatch
|
|
913
|
+
// every navigation and infinite-reload. Matches Turbo's
|
|
914
|
+
// head_snapshot.js elementWithoutNonce posture.
|
|
915
|
+
let sig = '';
|
|
916
|
+
for (const el of tracked) sig += outerHTMLForDiff(el);
|
|
917
|
+
return sig;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
function applySwap(doc, frameId, revalidating, href, incomingBuild) {
|
|
921
|
+
// Any clean swap (no importmap mismatch, including cache restores
|
|
922
|
+
// and frame swaps where we don't even run the mismatch check) is a
|
|
923
|
+
// signal that the user successfully navigated, so clear the reload
|
|
924
|
+
// flag. Otherwise a sequence "reload because of mismatch → Back to
|
|
925
|
+
// a cache restore → Forward to a deploy-bumped URL" would find the
|
|
926
|
+
// stale flag still set and suppress the second legitimate reload.
|
|
927
|
+
try {
|
|
928
|
+
if (typeof sessionStorage !== 'undefined' && (!href || frameId || revalidating)) {
|
|
929
|
+
sessionStorage.removeItem('webjs:importmap-reload');
|
|
930
|
+
}
|
|
931
|
+
} catch { /* ignore */ }
|
|
932
|
+
|
|
933
|
+
// Importmap-mismatch guard. Only fires for foreground navs (href
|
|
934
|
+
// present); revalidation passes href=null to keep cache restores
|
|
935
|
+
// soft. Skipped if a <webjs-frame> escape hatch is in play (frame
|
|
936
|
+
// swaps are intra-page and don't change the importmap).
|
|
937
|
+
if (href && !frameId && !revalidating) {
|
|
938
|
+
const currentTag = document.querySelector('script[type="importmap"]');
|
|
939
|
+
const currentBuild = currentTag ? currentTag.getAttribute('data-webjs-build') : null;
|
|
940
|
+
let mismatch = false;
|
|
941
|
+
if (incomingBuild && currentBuild) {
|
|
942
|
+
// Preferred path: compare per-response build id. Works even
|
|
943
|
+
// when the response body has no importmap (partial swap).
|
|
944
|
+
mismatch = incomingBuild !== currentBuild;
|
|
945
|
+
}
|
|
946
|
+
// An empty / absent build id on EITHER side means "version unknown":
|
|
947
|
+
// the server has not published an authoritative importmap yet (the
|
|
948
|
+
// warmup window, where a runtime-first-boot app resolves its vendor
|
|
949
|
+
// map over the first request), or the response predates the build
|
|
950
|
+
// header. In that state a hard reload is unsafe and destructive: it
|
|
951
|
+
// would fire repeatedly as the warming server's id flips from empty
|
|
952
|
+
// to its final value, wiping any half-filled form on the page. So we
|
|
953
|
+
// never hard-reload against an unknown id and leave `mismatch` false;
|
|
954
|
+
// the soft swap proceeds and the page settles once the server is
|
|
955
|
+
// warm. A real cross-deploy reload still fires, because both sides
|
|
956
|
+
// then carry non-empty, differing ids. (No importmap-textContent
|
|
957
|
+
// fallback: the published-id contract above supersedes it, and the
|
|
958
|
+
// textContent of a warming map drifts for the same reason the id does.)
|
|
959
|
+
// Generic `data-webjs-track="reload"` opt-in. ANY element in the
|
|
960
|
+
// head that the user marks gets included in the tracked-element
|
|
961
|
+
// signature. If the signature differs between current and incoming
|
|
962
|
+
// documents, hard-reload. Mirrors hotwired/turbo's
|
|
963
|
+
// data-turbo-track="reload" semantics (head_snapshot.js
|
|
964
|
+
// trackedElementSignature). Lets app authors tag arbitrary
|
|
965
|
+
// version-sensitive elements (CSS bundle <link>, deploy meta tag)
|
|
966
|
+
// for cross-deploy reload, not just the importmap.
|
|
967
|
+
//
|
|
968
|
+
// Importmap-specific data-webjs-build / X-Webjs-Build remain the
|
|
969
|
+
// primary mechanism because they ALSO work on partial responses
|
|
970
|
+
// (no head in the body). data-webjs-track is for elements that
|
|
971
|
+
// can't ride the build hash.
|
|
972
|
+
//
|
|
973
|
+
// Skip the check when the incoming response has no head content
|
|
974
|
+
// (X-Webjs-Have partial-fragment response). Without this guard
|
|
975
|
+
// a partial response would always mismatch any current tracked
|
|
976
|
+
// signature and falsely reload. With the guard, a partial
|
|
977
|
+
// response means "trust the build hash; don't decide based on
|
|
978
|
+
// missing head info." Comparing on full responses also catches
|
|
979
|
+
// added/removed track markers because empty `incomingSig`
|
|
980
|
+
// would correctly differ from a non-empty `currentSig`.
|
|
981
|
+
if (!mismatch && doc.head && doc.head.children.length > 0) {
|
|
982
|
+
const currentSig = trackedReloadSignature(document);
|
|
983
|
+
const incomingSig = trackedReloadSignature(doc);
|
|
984
|
+
if (currentSig !== incomingSig) mismatch = true;
|
|
985
|
+
}
|
|
986
|
+
if (mismatch && typeof location !== 'undefined') {
|
|
987
|
+
// Infinite-reload guard: if the importmap appears to genuinely
|
|
988
|
+
// change EVERY navigation (e.g. a developer is live-editing the
|
|
989
|
+
// pin file in dev, or a misbehaving CDN returns different
|
|
990
|
+
// jspm.io URLs each request), the user would experience a hard
|
|
991
|
+
// reload on every click. Use a one-shot sessionStorage flag:
|
|
992
|
+
// set before the first reload, cleared by the next successful
|
|
993
|
+
// swap. Two reloads BACK-TO-BACK (without an intervening clean
|
|
994
|
+
// nav) trip the guard.
|
|
995
|
+
try {
|
|
996
|
+
const flag = 'webjs:importmap-reload';
|
|
997
|
+
if (sessionStorage && sessionStorage.getItem(flag)) {
|
|
998
|
+
// Already reloaded once for an importmap mismatch and the
|
|
999
|
+
// next nav STILL mismatches: bail to the partial swap. The
|
|
1000
|
+
// user is on a stale importmap but at least the page
|
|
1001
|
+
// renders.
|
|
1002
|
+
sessionStorage.removeItem(flag);
|
|
1003
|
+
} else {
|
|
1004
|
+
if (sessionStorage) sessionStorage.setItem(flag, '1');
|
|
1005
|
+
location.href = href;
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
} catch {
|
|
1009
|
+
// sessionStorage unavailable (private mode w/ quota etc.):
|
|
1010
|
+
// fall through to a single reload like before.
|
|
1011
|
+
location.href = href;
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
} else if (!mismatch) {
|
|
1015
|
+
// A clean swap (no importmap mismatch) means we're back to
|
|
1016
|
+
// matching client/server importmaps. Clear the reload flag so
|
|
1017
|
+
// a future LEGITIMATE mismatch (e.g. a later deploy) gets a
|
|
1018
|
+
// fresh single-shot reload instead of being suppressed by a
|
|
1019
|
+
// stale flag from an unrelated earlier reload.
|
|
1020
|
+
try {
|
|
1021
|
+
if (typeof sessionStorage !== 'undefined') {
|
|
1022
|
+
sessionStorage.removeItem('webjs:importmap-reload');
|
|
1023
|
+
}
|
|
1024
|
+
} catch { /* ignore */ }
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
842
1028
|
// 1. webjs-frame escape hatch.
|
|
843
1029
|
if (frameId) {
|
|
844
1030
|
const target = document.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
|
|
@@ -1256,41 +1442,146 @@ function diffChildren(dst, src) {
|
|
|
1256
1442
|
*
|
|
1257
1443
|
* @param {HTMLHeadElement} newHead
|
|
1258
1444
|
*/
|
|
1445
|
+
|
|
1446
|
+
/**
|
|
1447
|
+
* Read the CSP nonce that the original page load published via
|
|
1448
|
+
* `<meta name="csp-nonce" content="...">`. Returns empty string when
|
|
1449
|
+
* no meta tag is present (apps without strict CSP).
|
|
1450
|
+
*
|
|
1451
|
+
* The meta tag is the contract: server emits it once at SSR time,
|
|
1452
|
+
* client reads it for every dynamically-created script. The browser
|
|
1453
|
+
* enforces CSP against the nonce the original page declared, NOT the
|
|
1454
|
+
* per-request nonce on subsequent navigations. So we always apply
|
|
1455
|
+
* THIS nonce, not the source-page nonce that arrived with the new
|
|
1456
|
+
* head fragment.
|
|
1457
|
+
*
|
|
1458
|
+
* Mirrors hotwired/turbo's `getCspNonce` in src/util.js. Not cached:
|
|
1459
|
+
* a single querySelector on document.head is cheap, and caching
|
|
1460
|
+
* would break if the user (or a test) inserted the meta tag late.
|
|
1461
|
+
*
|
|
1462
|
+
* @returns {string}
|
|
1463
|
+
*/
|
|
1464
|
+
function getCspNonce() {
|
|
1465
|
+
if (typeof document === 'undefined') return '';
|
|
1466
|
+
const meta = document.querySelector('meta[name="csp-nonce"]');
|
|
1467
|
+
// Read the `content` attribute, not the `.nonce` IDL property.
|
|
1468
|
+
// Turbo's getCspNonce in src/util.js falls back to `.nonce` first
|
|
1469
|
+
// because it can be called against script/link elements (where
|
|
1470
|
+
// browsers DO expose `.nonce` and additionally clear the
|
|
1471
|
+
// `nonce` attribute on document load). The `<meta name="csp-nonce">`
|
|
1472
|
+
// element webjs targets has no `.nonce` IDL (only script + link
|
|
1473
|
+
// elements do per HTML spec), so the only viable source is the
|
|
1474
|
+
// `content` attribute.
|
|
1475
|
+
return meta ? meta.getAttribute('content') || '' : '';
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
/**
|
|
1479
|
+
* Create a `<script>` clone of `source` that's safe to insert into the
|
|
1480
|
+
* live document under strict CSP. Copies every attribute EXCEPT
|
|
1481
|
+
* nonce (the source's nonce is from the new page's per-request token,
|
|
1482
|
+
* which the browser's CSP cache from the original page load will
|
|
1483
|
+
* reject), then applies the cached nonce from the meta tag. Re-emits
|
|
1484
|
+
* textContent so inline scripts execute as if first-loaded.
|
|
1485
|
+
*
|
|
1486
|
+
* @param {HTMLScriptElement} source
|
|
1487
|
+
* @returns {HTMLScriptElement}
|
|
1488
|
+
*/
|
|
1489
|
+
function cloneScriptWithCorrectNonce(source) {
|
|
1490
|
+
const script = document.createElement('script');
|
|
1491
|
+
for (const attr of source.attributes) {
|
|
1492
|
+
if (attr.name === 'nonce') continue;
|
|
1493
|
+
script.setAttribute(attr.name, attr.value);
|
|
1494
|
+
}
|
|
1495
|
+
const nonce = getCspNonce();
|
|
1496
|
+
if (nonce) {
|
|
1497
|
+
// Use setAttribute so the attribute is queryable
|
|
1498
|
+
// (`getAttribute('nonce')`, outerHTML serialization, etc.).
|
|
1499
|
+
// Per CSP3 the .nonce IDL property is the authoritative source
|
|
1500
|
+
// for the CSP check, but real browsers reflect setAttribute into
|
|
1501
|
+
// .nonce automatically. Test environments (linkedom) reflect only
|
|
1502
|
+
// one direction, so we set the attribute.
|
|
1503
|
+
script.setAttribute('nonce', nonce);
|
|
1504
|
+
}
|
|
1505
|
+
script.textContent = source.textContent;
|
|
1506
|
+
return script;
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
/**
|
|
1510
|
+
* Clone any head element while substituting the page-load CSP nonce
|
|
1511
|
+
* for the source's per-request nonce. Used for `<link rel="modulepreload"
|
|
1512
|
+
* nonce="...">` and any other nonce-carrying head element: browsers
|
|
1513
|
+
* gate cross-origin module preload by script-src nonce too, so the
|
|
1514
|
+
* per-request nonce from the new page's head would be blocked by the
|
|
1515
|
+
* browser's CSP cache from the original page load.
|
|
1516
|
+
*
|
|
1517
|
+
* Returns a cloneNode(true) for elements without a nonce attribute,
|
|
1518
|
+
* so non-CSP cases stay zero-cost.
|
|
1519
|
+
*
|
|
1520
|
+
* @param {Element} source
|
|
1521
|
+
* @returns {Element}
|
|
1522
|
+
*/
|
|
1523
|
+
function cloneElementWithCorrectNonce(source) {
|
|
1524
|
+
if (!source.hasAttribute('nonce')) return source.cloneNode(true);
|
|
1525
|
+
const clone = /** @type {Element} */ (source.cloneNode(true));
|
|
1526
|
+
const nonce = getCspNonce();
|
|
1527
|
+
if (nonce) {
|
|
1528
|
+
clone.setAttribute('nonce', nonce);
|
|
1529
|
+
} else {
|
|
1530
|
+
clone.removeAttribute('nonce');
|
|
1531
|
+
}
|
|
1532
|
+
return clone;
|
|
1533
|
+
}
|
|
1534
|
+
|
|
1535
|
+
/**
|
|
1536
|
+
* Return an `outerHTML` string suitable for head-diff comparison: strip
|
|
1537
|
+
* any nonce attribute so per-request nonces don't cause every script in
|
|
1538
|
+
* the head to look "changed" on every navigation. The original element
|
|
1539
|
+
* is left untouched (we clone first).
|
|
1540
|
+
*
|
|
1541
|
+
* Mirrors hotwired/turbo's `elementWithoutNonce` pattern in
|
|
1542
|
+
* src/core/drive/head_snapshot.js.
|
|
1543
|
+
*
|
|
1544
|
+
* @param {Element} el
|
|
1545
|
+
* @returns {string}
|
|
1546
|
+
*/
|
|
1547
|
+
function outerHTMLForDiff(el) {
|
|
1548
|
+
// Strip nonce from ANY element type. SCRIPT obviously, but also LINK
|
|
1549
|
+
// (modulepreload tags carry nonce per the recent CSP fix). Without
|
|
1550
|
+
// this, per-request nonces on link tags would cause the diff to
|
|
1551
|
+
// treat every preload as "changed", duplicating preloads on every
|
|
1552
|
+
// navigation.
|
|
1553
|
+
if (!el.hasAttribute('nonce')) return el.outerHTML;
|
|
1554
|
+
const clone = /** @type {Element} */ (el.cloneNode(true));
|
|
1555
|
+
clone.removeAttribute('nonce');
|
|
1556
|
+
return clone.outerHTML;
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1259
1559
|
function addNewHeadElements(newHead) {
|
|
1260
1560
|
const newTitle = newHead.querySelector('title');
|
|
1261
1561
|
if (newTitle) document.title = newTitle.textContent || '';
|
|
1262
1562
|
|
|
1263
1563
|
const currentSet = new Set();
|
|
1264
|
-
for (const el of document.head.children) currentSet.add(el
|
|
1564
|
+
for (const el of document.head.children) currentSet.add(outerHTMLForDiff(el));
|
|
1265
1565
|
|
|
1266
1566
|
for (const el of newHead.children) {
|
|
1267
1567
|
if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') {
|
|
1268
1568
|
// Skip: partial swaps keep the outer layout mounted, so the
|
|
1269
|
-
// existing importmap stays authoritative.
|
|
1270
|
-
//
|
|
1271
|
-
//
|
|
1272
|
-
//
|
|
1273
|
-
//
|
|
1274
|
-
const incoming = (el.textContent || '').trim();
|
|
1275
|
-
if (incoming) {
|
|
1276
|
-
const current = document.querySelector('script[type="importmap"]');
|
|
1277
|
-
const currentText = current ? (current.textContent || '').trim() : '';
|
|
1278
|
-
if (currentText && currentText !== incoming && typeof console !== 'undefined') {
|
|
1279
|
-
console.warn('[webjs] incoming page has a different importmap; keeping the current page\'s map. Cross-layout module aliasing may be inconsistent.');
|
|
1280
|
-
}
|
|
1281
|
-
}
|
|
1569
|
+
// existing importmap stays authoritative. Importmaps are
|
|
1570
|
+
// immutable once a script has run (modern browsers ignore
|
|
1571
|
+
// subsequent `<script type=importmap>`). Importmap-mismatch
|
|
1572
|
+
// detection lives at the applySwap entry: a mismatch there
|
|
1573
|
+
// triggers a full reload before we ever reach this loop.
|
|
1282
1574
|
continue;
|
|
1283
1575
|
}
|
|
1284
1576
|
if (el.tagName === 'BASE') continue;
|
|
1285
1577
|
if (el.tagName === 'TITLE') continue;
|
|
1286
|
-
if (!currentSet.has(el
|
|
1578
|
+
if (!currentSet.has(outerHTMLForDiff(el))) {
|
|
1287
1579
|
if (el.tagName === 'SCRIPT') {
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
document.head.appendChild(script);
|
|
1580
|
+
document.head.appendChild(
|
|
1581
|
+
cloneScriptWithCorrectNonce(/** @type {HTMLScriptElement} */ (el)),
|
|
1582
|
+
);
|
|
1292
1583
|
} else {
|
|
1293
|
-
document.head.appendChild(el
|
|
1584
|
+
document.head.appendChild(cloneElementWithCorrectNonce(el));
|
|
1294
1585
|
}
|
|
1295
1586
|
}
|
|
1296
1587
|
}
|
|
@@ -1307,35 +1598,34 @@ function mergeHead(newHead) {
|
|
|
1307
1598
|
for (const el of currentHead.children) {
|
|
1308
1599
|
if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') continue;
|
|
1309
1600
|
if (el.tagName === 'BASE') continue;
|
|
1310
|
-
currentSet.add(el
|
|
1601
|
+
currentSet.add(outerHTMLForDiff(el));
|
|
1311
1602
|
}
|
|
1312
1603
|
|
|
1313
1604
|
const newSet = new Set();
|
|
1314
1605
|
for (const el of newHead.children) {
|
|
1315
1606
|
if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') continue;
|
|
1316
1607
|
if (el.tagName === 'BASE') continue;
|
|
1317
|
-
newSet.add(el
|
|
1608
|
+
newSet.add(outerHTMLForDiff(el));
|
|
1318
1609
|
}
|
|
1319
1610
|
|
|
1320
1611
|
for (const el of [...currentHead.children]) {
|
|
1321
1612
|
if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') continue;
|
|
1322
1613
|
if (el.tagName === 'BASE') continue;
|
|
1323
1614
|
if (el.tagName === 'TITLE') continue;
|
|
1324
|
-
if (!newSet.has(el
|
|
1615
|
+
if (!newSet.has(outerHTMLForDiff(el))) el.remove();
|
|
1325
1616
|
}
|
|
1326
1617
|
|
|
1327
1618
|
for (const el of newHead.children) {
|
|
1328
1619
|
if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') continue;
|
|
1329
1620
|
if (el.tagName === 'BASE') continue;
|
|
1330
1621
|
if (el.tagName === 'TITLE') continue;
|
|
1331
|
-
if (!currentSet.has(el
|
|
1622
|
+
if (!currentSet.has(outerHTMLForDiff(el))) {
|
|
1332
1623
|
if (el.tagName === 'SCRIPT') {
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
currentHead.appendChild(script);
|
|
1624
|
+
currentHead.appendChild(
|
|
1625
|
+
cloneScriptWithCorrectNonce(/** @type {HTMLScriptElement} */ (el)),
|
|
1626
|
+
);
|
|
1337
1627
|
} else {
|
|
1338
|
-
currentHead.appendChild(el
|
|
1628
|
+
currentHead.appendChild(cloneElementWithCorrectNonce(el));
|
|
1339
1629
|
}
|
|
1340
1630
|
}
|
|
1341
1631
|
}
|
|
@@ -1380,10 +1670,7 @@ function forwardSuspenseResolvers(fetchedBody) {
|
|
|
1380
1670
|
/** @param {Element} container */
|
|
1381
1671
|
function reactivateScripts(container) {
|
|
1382
1672
|
for (const old of container.querySelectorAll('script')) {
|
|
1383
|
-
|
|
1384
|
-
for (const attr of old.attributes) script.setAttribute(attr.name, attr.value);
|
|
1385
|
-
script.textContent = old.textContent;
|
|
1386
|
-
old.replaceWith(script);
|
|
1673
|
+
old.replaceWith(cloneScriptWithCorrectNonce(/** @type {HTMLScriptElement} */ (old)));
|
|
1387
1674
|
}
|
|
1388
1675
|
}
|
|
1389
1676
|
|