@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.
@@ -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
- document.addEventListener('click', onClick, true);
154
- document.addEventListener('submit', onSubmit, true);
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, true);
173
- document.removeEventListener('submit', onSubmit, true);
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. Capture phase so we run before user
261
- * `@submit` handlers in component templates: they can call
262
- * `e.preventDefault()` or `e.stopImmediatePropagation()` first if they
263
- * want to handle the submission themselves (server-action RPC stubs do
264
- * this).
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 - already-matched markers may stomp inflight state; signal helps loading templates skip.
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 applySwap(doc, frameId, revalidating) {
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.outerHTML);
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. Warn if the incoming
1270
- // map differs: importmaps are immutable once a script has run
1271
- // (modern browsers ignore subsequent <script type=importmap>),
1272
- // so a mismatch means module resolution on this page won't match
1273
- // what SSR rendered against.
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.outerHTML)) {
1578
+ if (!currentSet.has(outerHTMLForDiff(el))) {
1287
1579
  if (el.tagName === 'SCRIPT') {
1288
- const script = document.createElement('script');
1289
- for (const attr of el.attributes) script.setAttribute(attr.name, attr.value);
1290
- script.textContent = el.textContent;
1291
- document.head.appendChild(script);
1580
+ document.head.appendChild(
1581
+ cloneScriptWithCorrectNonce(/** @type {HTMLScriptElement} */ (el)),
1582
+ );
1292
1583
  } else {
1293
- document.head.appendChild(el.cloneNode(true));
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.outerHTML);
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.outerHTML);
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.outerHTML)) el.remove();
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.outerHTML)) {
1622
+ if (!currentSet.has(outerHTMLForDiff(el))) {
1332
1623
  if (el.tagName === 'SCRIPT') {
1333
- const script = document.createElement('script');
1334
- for (const attr of el.attributes) script.setAttribute(attr.name, attr.value);
1335
- script.textContent = el.textContent;
1336
- currentHead.appendChild(script);
1624
+ currentHead.appendChild(
1625
+ cloneScriptWithCorrectNonce(/** @type {HTMLScriptElement} */ (el)),
1626
+ );
1337
1627
  } else {
1338
- currentHead.appendChild(el.cloneNode(true));
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
- const script = document.createElement('script');
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