@webjsdev/core 0.7.43 → 0.7.45

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@webjsdev/core",
3
- "version": "0.7.43",
3
+ "version": "0.7.45",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
package/src/component.js CHANGED
@@ -576,7 +576,7 @@ class WebComponentBase extends Base {
576
576
  `properties via the factory instead: \`class ${name} extends WebComponent({ ` +
577
577
  `count: Number })\`. Use the \`prop()\` helper for options ` +
578
578
  `(\`prop(Number, { reflect: true })\`) and set defaults in the ` +
579
- `constructor after \`super()\`. See https://docs.webjs.dev/docs/components.`,
579
+ `constructor after \`super()\`. See https://webjs.dev/docs/components.`,
580
580
  );
581
581
  }
582
582
  C = Object.getPrototypeOf(C);
@@ -764,19 +764,58 @@ function shouldFullLoadDuringParse(isPopState, frameId) {
764
764
  }
765
765
 
766
766
  /**
767
- * Dev-only diagnostic: the client router degraded a soft navigation to a full
768
- * page load. Records WHY (the `cause`) so the "why did my SPA nav do a full
769
- * reload?" question is answerable from the console instead of guessed at. Silent
770
- * in production and deduped per cause so a repeated trigger does not spam.
767
+ * The client router degraded a soft navigation. Records WHY (the `cause`), so
768
+ * "why did my SPA nav do a full reload?" is answerable instead of guessed at.
769
+ *
770
+ * Two channels, deliberately different in reach:
771
+ *
772
+ * - A **`webjs:navigation-fallback` event on `document`, in EVERY environment**,
773
+ * detail `{ cause, href, willReload }`. Dispatch convention matches
774
+ * `webjs:navigate` / `webjs:prefetch` / `webjs:navigation-error`.
775
+ * - A dev-only console warning, deduped per cause so a repeat does not spam.
776
+ *
777
+ * The event exists because the console warning alone made this class of bug
778
+ * UNDIAGNOSABLE in production (#1114). A degradation is correct behaviour, not
779
+ * an error, so nothing was logged and nothing was thrown, and a deployed app had
780
+ * no way to observe that a click had turned into a full document load. The
781
+ * user-visible symptoms (a loading spinner in the browser tab, a whole-document
782
+ * flash including preserved chrome) were then attributed to a styling problem
783
+ * for a full investigation cycle, because the actual cause emitted no signal.
784
+ *
785
+ * An event costs nothing when nobody listens, is greppable from a page console,
786
+ * and lets a deployed app wire this to analytics. It is NOT cancelable: by the
787
+ * time this fires the decision to degrade is already made and is the only safe
788
+ * option (#1015 chose a bounded full load over a heuristic recovery that could
789
+ * corrupt the DOM silently), so there is nothing for a listener to veto.
771
790
  *
772
791
  * @param {string} cause a short stable slug for the degradation reason
773
792
  * @param {string} href the destination the router fell back to loading
793
+ * @param {boolean} [willReload] true when a full document load follows (the
794
+ * default). False for a degradation that does NOT reload, so a listener can
795
+ * tell "this click became a document load" from "this background op was
796
+ * dropped", which are very different user-visible events.
774
797
  */
775
- function devWarnFallback(cause, href) {
798
+ function reportFallback(cause, href, willReload = true) {
799
+ if (typeof document !== 'undefined' && typeof CustomEvent !== 'undefined') {
800
+ try {
801
+ document.dispatchEvent(new CustomEvent('webjs:navigation-fallback', {
802
+ detail: { cause, href, willReload },
803
+ }));
804
+ } catch {
805
+ // A listener that throws must never turn a correct degradation into a
806
+ // broken navigation. Diagnostics are strictly best-effort.
807
+ }
808
+ }
776
809
  if (typeof process !== 'undefined' && process.env && process.env.NODE_ENV === 'production') return;
810
+ // Word the warning from `willReload`: several causes deliberately do NOT
811
+ // reload (a suppressed deploy reload, a discarded background revalidation),
812
+ // and claiming a full page load for those sends a reader hunting the wrong
813
+ // symptom.
777
814
  warnOnce(
778
815
  `fallback:${cause}`,
779
- `[webjs] client router fell back to a full page load (${cause}) navigating to ${href}. This is correct (no DOM corruption), just not a soft nav.`
816
+ willReload
817
+ ? `[webjs] client router fell back to a full page load (${cause}) navigating to ${href}. This is correct (no DOM corruption), just not a soft nav.`
818
+ : `[webjs] client router degraded a soft navigation (${cause}) for ${href}, without a full page load.`
780
819
  );
781
820
  }
782
821
 
@@ -1137,7 +1176,7 @@ async function performNavigation(href, isPopState, frameId) {
1137
1176
  // Scoped to frameless forward navs: popstate is browser-driven, and a frame
1138
1177
  // nav carries its own boundary element.
1139
1178
  if (shouldFullLoadDuringParse(isPopState, frameId) && typeof location !== 'undefined') {
1140
- devWarnFallback('readyState-loading', href);
1179
+ reportFallback('readyState-loading', href);
1141
1180
  location.href = href;
1142
1181
  return;
1143
1182
  }
@@ -1588,6 +1627,22 @@ function prefetch(href) {
1588
1627
  if (typeof fetch !== 'function') return;
1589
1628
  if (prefetchSaysSaveData()) return;
1590
1629
  const key = cacheKey(href);
1630
+ // Never prefetch the page we are already ON (#1106). The request cannot help
1631
+ // any future navigation, because a same-URL click short-circuits, and it
1632
+ // occupies one of the capped cache slots until its TTL expires. Fires
1633
+ // routinely: a hover's intent timer outlives the click it belongs to, so it
1634
+ // resolves after the swap has landed and now points at the current page.
1635
+ //
1636
+ // It ALSO removes one producer of the #1114 stale entry, though it is not the
1637
+ // cure for it (the anchor check in prefetchTake is). Worth being precise,
1638
+ // because the first version of this fix had the causality backwards: the
1639
+ // server short-circuits on LAYOUT segments only and ignores the page's own
1640
+ // boundary entry, so a self-prefetch is not a near-empty response, it is a
1641
+ // normal fragment anchored at the innermost LAYOUT. That fragment is
1642
+ // perfectly applicable from a sibling page and fails only from outside that
1643
+ // layout, which is exactly what the anchor check catches, and which a
1644
+ // never-clicked hover on a sibling link produces without this guard.
1645
+ if (typeof location !== 'undefined' && key === cacheKey(location.href)) return;
1591
1646
  if (prefetchInflight.has(key)) return;
1592
1647
  if (prefetchQueued.has(key)) return;
1593
1648
  const existing = prefetchCache.get(key);
@@ -1699,20 +1754,83 @@ function prefetchStore(key, entry) {
1699
1754
  }
1700
1755
  }
1701
1756
 
1757
+ /**
1758
+ * The `segment:routeKey` a cached fragment is ANCHORED at, or null when it
1759
+ * carries no boundary (a full document, or an unparseable body).
1760
+ *
1761
+ * A reduced response begins at the deepest boundary the server short-circuited
1762
+ * on, so its FIRST open-boundary comment is the join point the swap will look
1763
+ * for in the live DOM. Verified against the server: `have=/:/` yields a
1764
+ * fragment starting at `<!--wj:children:/:/-->` (anchored at the root, so it
1765
+ * applies on any page), while `have=/docs:/docs,/:/` yields one starting at
1766
+ * `<!--wj:children:/docs:/docs-->` (applies only under /docs).
1767
+ *
1768
+ * Read with a regex rather than a parse: this runs on the click path, and the
1769
+ * full parse happens moments later in `fetchAndApply` anyway. The pattern is
1770
+ * anchored on the literal marker the SSR emits, and a miss is treated as
1771
+ * "no constraint", so a shape change degrades to the old permissive behaviour
1772
+ * rather than silently rejecting every entry.
1773
+ *
1774
+ * @param {string} html
1775
+ * @returns {string | null}
1776
+ */
1777
+ function prefetchAnchor(html) {
1778
+ const m = /<!--wj:children:([^>]*?)-->/.exec(html || '');
1779
+ return m ? m[1] : null;
1780
+ }
1781
+
1702
1782
  /**
1703
1783
  * Consume a fresh speculative entry for `href`, removing it (a fragment
1704
1784
  * is single-use: once applied it becomes a real snapshot). Returns null
1705
- * on miss or when the entry has aged past the TTL.
1785
+ * on miss, when the entry has aged past the TTL, or when the live DOM no
1786
+ * longer offers the boundary the fragment is anchored at.
1706
1787
  *
1707
1788
  * @param {string} href
1789
+ * @param {string} [liveKeysOverride] the `X-Webjs-Have` view to validate the
1790
+ * anchor against, when the caller holds a truer one than the live DOM does.
1791
+ * Used for the optimistic loading skeleton, which deletes nested boundaries
1792
+ * before the fetch, so reading the DOM here would under-report them.
1708
1793
  * @returns {PrefetchEntry | null}
1709
1794
  */
1710
- function prefetchTake(href) {
1795
+ function prefetchTake(href, liveKeysOverride) {
1711
1796
  const key = cacheKey(href);
1712
1797
  const entry = prefetchCache.get(key);
1713
1798
  if (!entry) return null;
1799
+ if ((nowMs() - entry.at) >= PREFETCH_TTL) { prefetchCache.delete(key); return null; }
1800
+ // The reduced response VARIES on X-Webjs-Have, and this cache is a
1801
+ // client-side cache of that response, so it has to respect its own vary
1802
+ // dimension (#1114). The dimension is NOT the whole have string though: a
1803
+ // fragment is anchored at ONE boundary and applies to any live DOM that still
1804
+ // offers that boundary with the same route-key. Checking the whole string
1805
+ // instead discards entries that would have applied (prefetch /docs/x from /,
1806
+ // soft-nav to /blog, click: the fragment is anchored at the root, which /blog
1807
+ // also has), and worse, `applyOptimisticLoading` removes the page's own
1808
+ // boundary before the fetch to insert a loading skeleton, so on any route
1809
+ // with a `loading.{js,ts}` the live have is legitimately SHORTER at consume
1810
+ // time than at prefetch time with no navigation at all.
1811
+ //
1812
+ // So: consume when the anchor is still live, discard when it is not.
1813
+ // Discarding costs one round-trip; consuming a fragment whose anchor is gone
1814
+ // hands `applySwap` a tree sharing no boundary with the live DOM, and the
1815
+ // #1015 integrity degradation correctly turns that into a full page load,
1816
+ // which is the whole-document flash this guard exists to prevent.
1817
+ const anchor = prefetchAnchor(entry.html);
1818
+ if (anchor) {
1819
+ // `buildHaveHeader()` emits exactly comma-joined `segment:routeKey` entries,
1820
+ // so it is the single source of truth for the comparison format: membership
1821
+ // in it IS "the live DOM offers this boundary with this route-key". It
1822
+ // returns '' mid-parse, which rejects an anchored entry, and that is the
1823
+ // safe direction (a click during parse takes the full-load path regardless).
1824
+ const liveKeys = new Set(
1825
+ String(liveKeysOverride != null ? liveKeysOverride : (buildHaveHeader() || ''))
1826
+ .split(',').filter(Boolean)
1827
+ );
1828
+ if (!liveKeys.has(anchor)) {
1829
+ prefetchCache.delete(key);
1830
+ return null;
1831
+ }
1832
+ }
1714
1833
  prefetchCache.delete(key);
1715
- if ((nowMs() - entry.at) >= PREFETCH_TTL) return null;
1716
1834
  return entry;
1717
1835
  }
1718
1836
 
@@ -1944,7 +2062,15 @@ function handleNavigationError(href, status, error) {
1944
2062
  // cross-document nav). Fall back to a hard load so an unrecoverable case
1945
2063
  // is not a silent dead-end. This is the exception, reached only after
1946
2064
  // the event was not cancelled AND no in-place target exists.
1947
- if (typeof location !== 'undefined') location.href = href;
2065
+ //
2066
+ // Report it like every other degradation (#1114): this IS a click turning
2067
+ // into a document load, so an app watching `webjs:navigation-fallback` to
2068
+ // count full loads must see it. The preceding `webjs:navigation-error`
2069
+ // carries no `cause` / `willReload`, so it is not a substitute.
2070
+ if (typeof location !== 'undefined') {
2071
+ reportFallback('navigation-error-unrecoverable', href);
2072
+ location.href = href;
2073
+ }
1948
2074
  }
1949
2075
 
1950
2076
  /**
@@ -1994,13 +2120,19 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
1994
2120
  const busyFrame = frameId ? markFrameBusy(frameId, myToken) : null;
1995
2121
  try {
1996
2122
  try {
1997
- // Warm-cache fast path: a hover/focus/viewport prefetch may have
1998
- // already fetched this exact page (same X-Webjs-Have shell). Consume
1999
- // it instead of going to the network, so the click resolves with no
2000
- // round-trip. Only for plain GET navs without a frame target; form
2001
- // submissions and frame swaps always hit the server. The entry is
2002
- // single-use (prefetchTake removes it) and TTL-guarded inside take.
2003
- const prefetched = (method === 'GET' && !body && !frameId) ? prefetchTake(href) : null;
2123
+ // Warm-cache fast path: a hover/focus/viewport prefetch may already hold
2124
+ // this page. Consume it instead of going to the network, so the click
2125
+ // resolves with no round-trip. Only for plain GET navs without a frame
2126
+ // target; form submissions and frame swaps always hit the server. The entry
2127
+ // is single-use (prefetchTake removes it), TTL-guarded, and validated by its
2128
+ // ANCHOR rather than by an identical X-Webjs-Have (#1114): a fragment
2129
+ // applies wherever the boundary it starts at is still live, so an unrelated
2130
+ // navigation between the prefetch and this click does not disqualify it.
2131
+ // The optimistic skeleton has already deleted nested boundaries by now, so
2132
+ // pass the view captured before it ran.
2133
+ const prefetched = (method === 'GET' && !body && !frameId)
2134
+ ? prefetchTake(href, optimisticState ? optimisticState.haveKeys : undefined)
2135
+ : null;
2004
2136
  if (prefetched) {
2005
2137
  html = prefetched.html;
2006
2138
  incomingBuild = prefetched.build;
@@ -2753,14 +2885,17 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
2753
2885
  // user is on a stale importmap but at least the page
2754
2886
  // renders.
2755
2887
  sessionStorage.removeItem(flag);
2888
+ reportFallback('deploy-mismatch-reload-suppressed', href, false);
2756
2889
  } else {
2757
2890
  if (sessionStorage) sessionStorage.setItem(flag, '1');
2891
+ reportFallback('deploy-mismatch', href);
2758
2892
  location.href = href;
2759
2893
  return;
2760
2894
  }
2761
2895
  } catch {
2762
2896
  // sessionStorage unavailable (private mode w/ quota etc.):
2763
2897
  // fall through to a single reload like before.
2898
+ reportFallback('deploy-mismatch', href);
2764
2899
  location.href = href;
2765
2900
  return;
2766
2901
  }
@@ -2896,7 +3031,7 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
2896
3031
  // is already viewing a page, so a background op must never yank them
2897
3032
  // through a hard load; it takes the in-place path below.
2898
3033
  if (href && !revalidating && typeof location !== 'undefined') {
2899
- devWarnFallback(!here ? 'live-boundaries-malformed'
3034
+ reportFallback(!here ? 'live-boundaries-malformed'
2900
3035
  : !there ? 'incoming-boundaries-malformed'
2901
3036
  : 'no-shared-boundary', href);
2902
3037
  location.href = href;
@@ -2909,7 +3044,7 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
2909
3044
  // fragment, so the full-body swap below would wipe the shell from a
2910
3045
  // background op. Doing nothing is the only safe degradation here.
2911
3046
  if (href && revalidating) {
2912
- devWarnFallback('revalidation-discarded', href);
3047
+ reportFallback('revalidation-discarded', href, false);
2913
3048
  return 'discard';
2914
3049
  }
2915
3050
 
@@ -3586,6 +3721,16 @@ function applyOptimisticLoading() {
3586
3721
  if (deepest === null || tpl === null) return null;
3587
3722
 
3588
3723
  const slot = slots.get(deepest);
3724
+ // Snapshot the boundary keys BEFORE the skeleton wipes them (#1114). The
3725
+ // range below deletes everything between this slot's markers, which includes
3726
+ // every NESTED boundary comment, so `buildHaveHeader()` afterwards is
3727
+ // legitimately shorter than the page really is. `prefetchTake` validates a
3728
+ // cached fragment's anchor against the live boundaries, and without this it
3729
+ // would judge against the skeleton's truncated view: on an app whose only
3730
+ // loading.{js,ts} sits at the root, every deeper anchor vanishes and NO
3731
+ // prefetch is ever consumable. Carried on the state that already threads to
3732
+ // fetchAndApply, so nothing new has to be plumbed.
3733
+ const haveKeys = buildHaveHeader();
3589
3734
  /** @type {Node[]} */
3590
3735
  const oldChildren = [];
3591
3736
  for (let n = slot.start.nextSibling; n && n !== slot.end; n = n.nextSibling) {
@@ -3597,10 +3742,10 @@ function applyOptimisticLoading() {
3597
3742
  range.setEndBefore(slot.end);
3598
3743
  range.deleteContents();
3599
3744
  slot.start.parentNode.insertBefore(tpl.content.cloneNode(true), slot.end);
3600
- return { slot, oldChildren, token: currentNavigationToken };
3745
+ return { slot, oldChildren, token: currentNavigationToken, haveKeys };
3601
3746
  }
3602
3747
 
3603
- /** @param {{ slot: { start: Comment, end: Comment }, oldChildren: Node[], token: number } | null} state */
3748
+ /** @param {{ slot: { start: Comment, end: Comment }, oldChildren: Node[], token: number, haveKeys?: string } | null} state */
3604
3749
  function restoreOptimistic(state) {
3605
3750
  if (!state) return;
3606
3751
  // A newer nav superseded the one that captured this state: don't
@@ -4255,6 +4400,8 @@ export {
4255
4400
  prefetchHasHoverPointer as _prefetchHasHoverPointer,
4256
4401
  prefetch as _prefetch,
4257
4402
  prefetchTake as _prefetchTake,
4403
+ prefetchAnchor as _prefetchAnchor,
4404
+ applyOptimisticLoading as _applyOptimisticLoading,
4258
4405
  prefetchSaysSaveData as _prefetchSaysSaveData,
4259
4406
  readStreamedShell as _readStreamedShell,
4260
4407
  takeResolveUnit as _takeResolveUnit,