@webjsdev/core 0.7.1 → 0.7.2

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.
@@ -596,7 +596,7 @@ async function performNavigation(href, isPopState, frameId) {
596
596
  if (cached) {
597
597
  const cachedDoc = parseHTML(cached.html);
598
598
  if (cachedDoc) {
599
- applySwap(cachedDoc, frameId, /* revalidating */ true);
599
+ applySwap(cachedDoc, frameId, /* revalidating */ true, /* href */ null);
600
600
  // Restore window scroll to where the user left it.
601
601
  if (typeof window !== 'undefined') {
602
602
  window.scrollTo(cached.scrollX, cached.scrollY);
@@ -734,6 +734,7 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
734
734
  method = method || 'GET';
735
735
  const myToken = typeof token === 'number' ? token : currentNavigationToken;
736
736
  let html;
737
+ let incomingBuild = null;
737
738
  /** @type {string} */
738
739
  let finalUrl = href;
739
740
  try {
@@ -782,6 +783,12 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
782
783
  // (turbo/src/core/drive/navigator.js:92-107). Critical for the
783
784
  // standard server-rendered validation pattern: 422 + re-rendered
784
785
  // form with errors keeps the user's typed input and shows context.
786
+ // Capture the server's build hash header BEFORE reading the body.
787
+ // The header is set on every SSR response, including X-Webjs-Have
788
+ // partial responses where the body has no head and no importmap
789
+ // tag to compare. The applySwap importmap-mismatch guard reads
790
+ // this to detect deploys that bumped the vendor pin.
791
+ incomingBuild = resp.headers.get('x-webjs-build');
785
792
  html = await resp.text();
786
793
  } catch (err) {
787
794
  // Aborted by a newer navigation: let it run, don't fall back.
@@ -801,7 +808,7 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
801
808
  const doc = parseHTML(html);
802
809
  if (!doc) { location.href = href; return; }
803
810
 
804
- applySwap(doc, frameId, false);
811
+ applySwap(doc, frameId, false, finalUrl, incomingBuild);
805
812
 
806
813
  if (recordHistory) history.pushState(null, '', finalUrl);
807
814
 
@@ -834,11 +841,173 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
834
841
  * Picks the most-scoped match: explicit webjs-frame > deepest shared
835
842
  * layout marker > full body swap.
836
843
  *
844
+ * If the incoming page carries a different importmap from the current
845
+ * page (typical after a deploy that bumped a vendor pin), partial swap
846
+ * is unsafe: importmaps are immutable once applied, so the new page
847
+ * would resolve modules against the stale URLs. We fall back to a full
848
+ * page load via `location.assign(href)`. Mirrors Turbo's
849
+ * `tracked_element_mismatch` reload, applied specifically to
850
+ * importmaps. Called with `href = null` for revalidation flows (which
851
+ * never trigger a hard reload).
852
+ *
853
+ * Detection uses the `X-Webjs-Build` response header (read by the
854
+ * fetch path and passed in as `incomingBuild`), compared against the
855
+ * current page's `data-webjs-build`. The header is set on EVERY SSR
856
+ * response, including X-Webjs-Have partial responses that omit the
857
+ * head and importmap entirely, and it carries the PUBLISHED build id,
858
+ * which the server advertises only once the importmap is final. A hard
859
+ * reload fires only when both ids are present and differ (a real
860
+ * cross-deploy). An empty / absent id on either side means "version
861
+ * unknown" (a warming runtime-first-boot server, or a response that
862
+ * predates the header) and never triggers a reload, so the warmup
863
+ * window cannot wipe a half-filled form.
864
+ *
837
865
  * @param {Document} doc
838
866
  * @param {string | null} frameId
839
- * @param {boolean} revalidating Restore from cache - already-matched markers may stomp inflight state; signal helps loading templates skip.
867
+ * @param {boolean} revalidating Restore from cache; already-matched markers may stomp inflight state, signal helps loading templates skip.
868
+ * @param {string | null} [href] Target URL for hard-reload fallback on importmap mismatch.
869
+ * @param {string | null} [incomingBuild] X-Webjs-Build header from the response, or null.
870
+ */
871
+ /**
872
+ * Compute the signature of all `data-webjs-track="reload"` elements
873
+ * in the head of `root`. Returns the concatenation of each element's
874
+ * `outerHTML`, in document order. Two documents with identical
875
+ * tracked-element sets produce identical signatures; any change in
876
+ * attributes, content, or set membership produces a different one.
877
+ *
878
+ * Mirrors hotwired/turbo's `head_snapshot.js` `trackedElementSignature`
879
+ * (the data-turbo-track="reload" mechanism). Used by applySwap as a
880
+ * generic opt-in next to the importmap-specific build hash.
881
+ *
882
+ * Returns the empty string when `root` has no head (e.g. an
883
+ * X-Webjs-Have partial response) or when no elements opt in.
884
+ *
885
+ * @param {Document | undefined} root
886
+ * @returns {string}
840
887
  */
841
- function applySwap(doc, frameId, revalidating) {
888
+ function trackedReloadSignature(root) {
889
+ if (!root || !root.head) return '';
890
+ const tracked = root.head.querySelectorAll('[data-webjs-track="reload"]');
891
+ if (!tracked.length) return '';
892
+ // Use outerHTMLForDiff so the CSP nonce (which rotates per
893
+ // request) is stripped before signature comparison. Without this,
894
+ // a nonced tracked script like `<script nonce="${cspNonce()}"
895
+ // data-webjs-track="reload" src="/build.js?v=42">` would mismatch
896
+ // every navigation and infinite-reload. Matches Turbo's
897
+ // head_snapshot.js elementWithoutNonce posture.
898
+ let sig = '';
899
+ for (const el of tracked) sig += outerHTMLForDiff(el);
900
+ return sig;
901
+ }
902
+
903
+ function applySwap(doc, frameId, revalidating, href, incomingBuild) {
904
+ // Any clean swap (no importmap mismatch, including cache restores
905
+ // and frame swaps where we don't even run the mismatch check) is a
906
+ // signal that the user successfully navigated, so clear the reload
907
+ // flag. Otherwise a sequence "reload because of mismatch → Back to
908
+ // a cache restore → Forward to a deploy-bumped URL" would find the
909
+ // stale flag still set and suppress the second legitimate reload.
910
+ try {
911
+ if (typeof sessionStorage !== 'undefined' && (!href || frameId || revalidating)) {
912
+ sessionStorage.removeItem('webjs:importmap-reload');
913
+ }
914
+ } catch { /* ignore */ }
915
+
916
+ // Importmap-mismatch guard. Only fires for foreground navs (href
917
+ // present); revalidation passes href=null to keep cache restores
918
+ // soft. Skipped if a <webjs-frame> escape hatch is in play (frame
919
+ // swaps are intra-page and don't change the importmap).
920
+ if (href && !frameId && !revalidating) {
921
+ const currentTag = document.querySelector('script[type="importmap"]');
922
+ const currentBuild = currentTag ? currentTag.getAttribute('data-webjs-build') : null;
923
+ let mismatch = false;
924
+ if (incomingBuild && currentBuild) {
925
+ // Preferred path: compare per-response build id. Works even
926
+ // when the response body has no importmap (partial swap).
927
+ mismatch = incomingBuild !== currentBuild;
928
+ }
929
+ // An empty / absent build id on EITHER side means "version unknown":
930
+ // the server has not published an authoritative importmap yet (the
931
+ // warmup window, where a runtime-first-boot app resolves its vendor
932
+ // map over the first request), or the response predates the build
933
+ // header. In that state a hard reload is unsafe and destructive: it
934
+ // would fire repeatedly as the warming server's id flips from empty
935
+ // to its final value, wiping any half-filled form on the page. So we
936
+ // never hard-reload against an unknown id and leave `mismatch` false;
937
+ // the soft swap proceeds and the page settles once the server is
938
+ // warm. A real cross-deploy reload still fires, because both sides
939
+ // then carry non-empty, differing ids. (No importmap-textContent
940
+ // fallback: the published-id contract above supersedes it, and the
941
+ // textContent of a warming map drifts for the same reason the id does.)
942
+ // Generic `data-webjs-track="reload"` opt-in. ANY element in the
943
+ // head that the user marks gets included in the tracked-element
944
+ // signature. If the signature differs between current and incoming
945
+ // documents, hard-reload. Mirrors hotwired/turbo's
946
+ // data-turbo-track="reload" semantics (head_snapshot.js
947
+ // trackedElementSignature). Lets app authors tag arbitrary
948
+ // version-sensitive elements (CSS bundle <link>, deploy meta tag)
949
+ // for cross-deploy reload, not just the importmap.
950
+ //
951
+ // Importmap-specific data-webjs-build / X-Webjs-Build remain the
952
+ // primary mechanism because they ALSO work on partial responses
953
+ // (no head in the body). data-webjs-track is for elements that
954
+ // can't ride the build hash.
955
+ //
956
+ // Skip the check when the incoming response has no head content
957
+ // (X-Webjs-Have partial-fragment response). Without this guard
958
+ // a partial response would always mismatch any current tracked
959
+ // signature and falsely reload. With the guard, a partial
960
+ // response means "trust the build hash; don't decide based on
961
+ // missing head info." Comparing on full responses also catches
962
+ // added/removed track markers because empty `incomingSig`
963
+ // would correctly differ from a non-empty `currentSig`.
964
+ if (!mismatch && doc.head && doc.head.children.length > 0) {
965
+ const currentSig = trackedReloadSignature(document);
966
+ const incomingSig = trackedReloadSignature(doc);
967
+ if (currentSig !== incomingSig) mismatch = true;
968
+ }
969
+ if (mismatch && typeof location !== 'undefined') {
970
+ // Infinite-reload guard: if the importmap appears to genuinely
971
+ // change EVERY navigation (e.g. a developer is live-editing the
972
+ // pin file in dev, or a misbehaving CDN returns different
973
+ // jspm.io URLs each request), the user would experience a hard
974
+ // reload on every click. Use a one-shot sessionStorage flag:
975
+ // set before the first reload, cleared by the next successful
976
+ // swap. Two reloads BACK-TO-BACK (without an intervening clean
977
+ // nav) trip the guard.
978
+ try {
979
+ const flag = 'webjs:importmap-reload';
980
+ if (sessionStorage && sessionStorage.getItem(flag)) {
981
+ // Already reloaded once for an importmap mismatch and the
982
+ // next nav STILL mismatches: bail to the partial swap. The
983
+ // user is on a stale importmap but at least the page
984
+ // renders.
985
+ sessionStorage.removeItem(flag);
986
+ } else {
987
+ if (sessionStorage) sessionStorage.setItem(flag, '1');
988
+ location.href = href;
989
+ return;
990
+ }
991
+ } catch {
992
+ // sessionStorage unavailable (private mode w/ quota etc.):
993
+ // fall through to a single reload like before.
994
+ location.href = href;
995
+ return;
996
+ }
997
+ } else if (!mismatch) {
998
+ // A clean swap (no importmap mismatch) means we're back to
999
+ // matching client/server importmaps. Clear the reload flag so
1000
+ // a future LEGITIMATE mismatch (e.g. a later deploy) gets a
1001
+ // fresh single-shot reload instead of being suppressed by a
1002
+ // stale flag from an unrelated earlier reload.
1003
+ try {
1004
+ if (typeof sessionStorage !== 'undefined') {
1005
+ sessionStorage.removeItem('webjs:importmap-reload');
1006
+ }
1007
+ } catch { /* ignore */ }
1008
+ }
1009
+ }
1010
+
842
1011
  // 1. webjs-frame escape hatch.
843
1012
  if (frameId) {
844
1013
  const target = document.querySelector(`webjs-frame#${CSS.escape(frameId)}`);
@@ -1256,41 +1425,146 @@ function diffChildren(dst, src) {
1256
1425
  *
1257
1426
  * @param {HTMLHeadElement} newHead
1258
1427
  */
1428
+
1429
+ /**
1430
+ * Read the CSP nonce that the original page load published via
1431
+ * `<meta name="csp-nonce" content="...">`. Returns empty string when
1432
+ * no meta tag is present (apps without strict CSP).
1433
+ *
1434
+ * The meta tag is the contract: server emits it once at SSR time,
1435
+ * client reads it for every dynamically-created script. The browser
1436
+ * enforces CSP against the nonce the original page declared, NOT the
1437
+ * per-request nonce on subsequent navigations. So we always apply
1438
+ * THIS nonce, not the source-page nonce that arrived with the new
1439
+ * head fragment.
1440
+ *
1441
+ * Mirrors hotwired/turbo's `getCspNonce` in src/util.js. Not cached:
1442
+ * a single querySelector on document.head is cheap, and caching
1443
+ * would break if the user (or a test) inserted the meta tag late.
1444
+ *
1445
+ * @returns {string}
1446
+ */
1447
+ function getCspNonce() {
1448
+ if (typeof document === 'undefined') return '';
1449
+ const meta = document.querySelector('meta[name="csp-nonce"]');
1450
+ // Read the `content` attribute, not the `.nonce` IDL property.
1451
+ // Turbo's getCspNonce in src/util.js falls back to `.nonce` first
1452
+ // because it can be called against script/link elements (where
1453
+ // browsers DO expose `.nonce` and additionally clear the
1454
+ // `nonce` attribute on document load). The `<meta name="csp-nonce">`
1455
+ // element webjs targets has no `.nonce` IDL (only script + link
1456
+ // elements do per HTML spec), so the only viable source is the
1457
+ // `content` attribute.
1458
+ return meta ? meta.getAttribute('content') || '' : '';
1459
+ }
1460
+
1461
+ /**
1462
+ * Create a `<script>` clone of `source` that's safe to insert into the
1463
+ * live document under strict CSP. Copies every attribute EXCEPT
1464
+ * nonce (the source's nonce is from the new page's per-request token,
1465
+ * which the browser's CSP cache from the original page load will
1466
+ * reject), then applies the cached nonce from the meta tag. Re-emits
1467
+ * textContent so inline scripts execute as if first-loaded.
1468
+ *
1469
+ * @param {HTMLScriptElement} source
1470
+ * @returns {HTMLScriptElement}
1471
+ */
1472
+ function cloneScriptWithCorrectNonce(source) {
1473
+ const script = document.createElement('script');
1474
+ for (const attr of source.attributes) {
1475
+ if (attr.name === 'nonce') continue;
1476
+ script.setAttribute(attr.name, attr.value);
1477
+ }
1478
+ const nonce = getCspNonce();
1479
+ if (nonce) {
1480
+ // Use setAttribute so the attribute is queryable
1481
+ // (`getAttribute('nonce')`, outerHTML serialization, etc.).
1482
+ // Per CSP3 the .nonce IDL property is the authoritative source
1483
+ // for the CSP check, but real browsers reflect setAttribute into
1484
+ // .nonce automatically. Test environments (linkedom) reflect only
1485
+ // one direction, so we set the attribute.
1486
+ script.setAttribute('nonce', nonce);
1487
+ }
1488
+ script.textContent = source.textContent;
1489
+ return script;
1490
+ }
1491
+
1492
+ /**
1493
+ * Clone any head element while substituting the page-load CSP nonce
1494
+ * for the source's per-request nonce. Used for `<link rel="modulepreload"
1495
+ * nonce="...">` and any other nonce-carrying head element: browsers
1496
+ * gate cross-origin module preload by script-src nonce too, so the
1497
+ * per-request nonce from the new page's head would be blocked by the
1498
+ * browser's CSP cache from the original page load.
1499
+ *
1500
+ * Returns a cloneNode(true) for elements without a nonce attribute,
1501
+ * so non-CSP cases stay zero-cost.
1502
+ *
1503
+ * @param {Element} source
1504
+ * @returns {Element}
1505
+ */
1506
+ function cloneElementWithCorrectNonce(source) {
1507
+ if (!source.hasAttribute('nonce')) return source.cloneNode(true);
1508
+ const clone = /** @type {Element} */ (source.cloneNode(true));
1509
+ const nonce = getCspNonce();
1510
+ if (nonce) {
1511
+ clone.setAttribute('nonce', nonce);
1512
+ } else {
1513
+ clone.removeAttribute('nonce');
1514
+ }
1515
+ return clone;
1516
+ }
1517
+
1518
+ /**
1519
+ * Return an `outerHTML` string suitable for head-diff comparison: strip
1520
+ * any nonce attribute so per-request nonces don't cause every script in
1521
+ * the head to look "changed" on every navigation. The original element
1522
+ * is left untouched (we clone first).
1523
+ *
1524
+ * Mirrors hotwired/turbo's `elementWithoutNonce` pattern in
1525
+ * src/core/drive/head_snapshot.js.
1526
+ *
1527
+ * @param {Element} el
1528
+ * @returns {string}
1529
+ */
1530
+ function outerHTMLForDiff(el) {
1531
+ // Strip nonce from ANY element type. SCRIPT obviously, but also LINK
1532
+ // (modulepreload tags carry nonce per the recent CSP fix). Without
1533
+ // this, per-request nonces on link tags would cause the diff to
1534
+ // treat every preload as "changed", duplicating preloads on every
1535
+ // navigation.
1536
+ if (!el.hasAttribute('nonce')) return el.outerHTML;
1537
+ const clone = /** @type {Element} */ (el.cloneNode(true));
1538
+ clone.removeAttribute('nonce');
1539
+ return clone.outerHTML;
1540
+ }
1541
+
1259
1542
  function addNewHeadElements(newHead) {
1260
1543
  const newTitle = newHead.querySelector('title');
1261
1544
  if (newTitle) document.title = newTitle.textContent || '';
1262
1545
 
1263
1546
  const currentSet = new Set();
1264
- for (const el of document.head.children) currentSet.add(el.outerHTML);
1547
+ for (const el of document.head.children) currentSet.add(outerHTMLForDiff(el));
1265
1548
 
1266
1549
  for (const el of newHead.children) {
1267
1550
  if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') {
1268
1551
  // 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
- }
1552
+ // existing importmap stays authoritative. Importmaps are
1553
+ // immutable once a script has run (modern browsers ignore
1554
+ // subsequent `<script type=importmap>`). Importmap-mismatch
1555
+ // detection lives at the applySwap entry: a mismatch there
1556
+ // triggers a full reload before we ever reach this loop.
1282
1557
  continue;
1283
1558
  }
1284
1559
  if (el.tagName === 'BASE') continue;
1285
1560
  if (el.tagName === 'TITLE') continue;
1286
- if (!currentSet.has(el.outerHTML)) {
1561
+ if (!currentSet.has(outerHTMLForDiff(el))) {
1287
1562
  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);
1563
+ document.head.appendChild(
1564
+ cloneScriptWithCorrectNonce(/** @type {HTMLScriptElement} */ (el)),
1565
+ );
1292
1566
  } else {
1293
- document.head.appendChild(el.cloneNode(true));
1567
+ document.head.appendChild(cloneElementWithCorrectNonce(el));
1294
1568
  }
1295
1569
  }
1296
1570
  }
@@ -1307,35 +1581,34 @@ function mergeHead(newHead) {
1307
1581
  for (const el of currentHead.children) {
1308
1582
  if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') continue;
1309
1583
  if (el.tagName === 'BASE') continue;
1310
- currentSet.add(el.outerHTML);
1584
+ currentSet.add(outerHTMLForDiff(el));
1311
1585
  }
1312
1586
 
1313
1587
  const newSet = new Set();
1314
1588
  for (const el of newHead.children) {
1315
1589
  if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') continue;
1316
1590
  if (el.tagName === 'BASE') continue;
1317
- newSet.add(el.outerHTML);
1591
+ newSet.add(outerHTMLForDiff(el));
1318
1592
  }
1319
1593
 
1320
1594
  for (const el of [...currentHead.children]) {
1321
1595
  if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') continue;
1322
1596
  if (el.tagName === 'BASE') continue;
1323
1597
  if (el.tagName === 'TITLE') continue;
1324
- if (!newSet.has(el.outerHTML)) el.remove();
1598
+ if (!newSet.has(outerHTMLForDiff(el))) el.remove();
1325
1599
  }
1326
1600
 
1327
1601
  for (const el of newHead.children) {
1328
1602
  if (el.tagName === 'SCRIPT' && el.getAttribute('type') === 'importmap') continue;
1329
1603
  if (el.tagName === 'BASE') continue;
1330
1604
  if (el.tagName === 'TITLE') continue;
1331
- if (!currentSet.has(el.outerHTML)) {
1605
+ if (!currentSet.has(outerHTMLForDiff(el))) {
1332
1606
  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);
1607
+ currentHead.appendChild(
1608
+ cloneScriptWithCorrectNonce(/** @type {HTMLScriptElement} */ (el)),
1609
+ );
1337
1610
  } else {
1338
- currentHead.appendChild(el.cloneNode(true));
1611
+ currentHead.appendChild(cloneElementWithCorrectNonce(el));
1339
1612
  }
1340
1613
  }
1341
1614
  }
@@ -1380,10 +1653,7 @@ function forwardSuspenseResolvers(fetchedBody) {
1380
1653
  /** @param {Element} container */
1381
1654
  function reactivateScripts(container) {
1382
1655
  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);
1656
+ old.replaceWith(cloneScriptWithCorrectNonce(/** @type {HTMLScriptElement} */ (old)));
1387
1657
  }
1388
1658
  }
1389
1659