@webjsdev/core 0.7.44 → 0.7.46

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.
@@ -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);
@@ -1619,7 +1674,16 @@ function prefetch(href) {
1619
1674
  const headers = { 'x-webjs-router': '1', 'x-webjs-prefetch': '1' };
1620
1675
  if (have) headers['x-webjs-have'] = have;
1621
1676
 
1622
- fetch(href, { method: 'GET', headers, credentials: 'same-origin' })
1677
+ // `no-cache` (revalidate, NOT bypass) is load-bearing (#1131): the deploy
1678
+ // check below reads x-webjs-build / x-webjs-src off this response, and a
1679
+ // page served with a browser max-age would otherwise satisfy the fetch
1680
+ // wholly from the HTTP cache, replaying pre-deploy ids. The check would then
1681
+ // compare two equally stale values and skip the eviction, so a deploy stayed
1682
+ // invisible for the whole freshness window plus one stale-while-revalidate
1683
+ // serving per URL. With stable page ETags a forced revalidation is a
1684
+ // conditional request answered 304, so the cost is a header round-trip, not
1685
+ // a re-download.
1686
+ fetch(href, { method: 'GET', headers, credentials: 'same-origin', cache: 'no-cache' })
1623
1687
  .then(async (resp) => {
1624
1688
  const ctype = resp.headers.get('content-type') || '';
1625
1689
  if (!/^text\/html\b/i.test(ctype)) return;
@@ -1699,20 +1763,83 @@ function prefetchStore(key, entry) {
1699
1763
  }
1700
1764
  }
1701
1765
 
1766
+ /**
1767
+ * The `segment:routeKey` a cached fragment is ANCHORED at, or null when it
1768
+ * carries no boundary (a full document, or an unparseable body).
1769
+ *
1770
+ * A reduced response begins at the deepest boundary the server short-circuited
1771
+ * on, so its FIRST open-boundary comment is the join point the swap will look
1772
+ * for in the live DOM. Verified against the server: `have=/:/` yields a
1773
+ * fragment starting at `<!--wj:children:/:/-->` (anchored at the root, so it
1774
+ * applies on any page), while `have=/docs:/docs,/:/` yields one starting at
1775
+ * `<!--wj:children:/docs:/docs-->` (applies only under /docs).
1776
+ *
1777
+ * Read with a regex rather than a parse: this runs on the click path, and the
1778
+ * full parse happens moments later in `fetchAndApply` anyway. The pattern is
1779
+ * anchored on the literal marker the SSR emits, and a miss is treated as
1780
+ * "no constraint", so a shape change degrades to the old permissive behaviour
1781
+ * rather than silently rejecting every entry.
1782
+ *
1783
+ * @param {string} html
1784
+ * @returns {string | null}
1785
+ */
1786
+ function prefetchAnchor(html) {
1787
+ const m = /<!--wj:children:([^>]*?)-->/.exec(html || '');
1788
+ return m ? m[1] : null;
1789
+ }
1790
+
1702
1791
  /**
1703
1792
  * Consume a fresh speculative entry for `href`, removing it (a fragment
1704
1793
  * is single-use: once applied it becomes a real snapshot). Returns null
1705
- * on miss or when the entry has aged past the TTL.
1794
+ * on miss, when the entry has aged past the TTL, or when the live DOM no
1795
+ * longer offers the boundary the fragment is anchored at.
1706
1796
  *
1707
1797
  * @param {string} href
1798
+ * @param {string} [liveKeysOverride] the `X-Webjs-Have` view to validate the
1799
+ * anchor against, when the caller holds a truer one than the live DOM does.
1800
+ * Used for the optimistic loading skeleton, which deletes nested boundaries
1801
+ * before the fetch, so reading the DOM here would under-report them.
1708
1802
  * @returns {PrefetchEntry | null}
1709
1803
  */
1710
- function prefetchTake(href) {
1804
+ function prefetchTake(href, liveKeysOverride) {
1711
1805
  const key = cacheKey(href);
1712
1806
  const entry = prefetchCache.get(key);
1713
1807
  if (!entry) return null;
1808
+ if ((nowMs() - entry.at) >= PREFETCH_TTL) { prefetchCache.delete(key); return null; }
1809
+ // The reduced response VARIES on X-Webjs-Have, and this cache is a
1810
+ // client-side cache of that response, so it has to respect its own vary
1811
+ // dimension (#1114). The dimension is NOT the whole have string though: a
1812
+ // fragment is anchored at ONE boundary and applies to any live DOM that still
1813
+ // offers that boundary with the same route-key. Checking the whole string
1814
+ // instead discards entries that would have applied (prefetch /docs/x from /,
1815
+ // soft-nav to /blog, click: the fragment is anchored at the root, which /blog
1816
+ // also has), and worse, `applyOptimisticLoading` removes the page's own
1817
+ // boundary before the fetch to insert a loading skeleton, so on any route
1818
+ // with a `loading.{js,ts}` the live have is legitimately SHORTER at consume
1819
+ // time than at prefetch time with no navigation at all.
1820
+ //
1821
+ // So: consume when the anchor is still live, discard when it is not.
1822
+ // Discarding costs one round-trip; consuming a fragment whose anchor is gone
1823
+ // hands `applySwap` a tree sharing no boundary with the live DOM, and the
1824
+ // #1015 integrity degradation correctly turns that into a full page load,
1825
+ // which is the whole-document flash this guard exists to prevent.
1826
+ const anchor = prefetchAnchor(entry.html);
1827
+ if (anchor) {
1828
+ // `buildHaveHeader()` emits exactly comma-joined `segment:routeKey` entries,
1829
+ // so it is the single source of truth for the comparison format: membership
1830
+ // in it IS "the live DOM offers this boundary with this route-key". It
1831
+ // returns '' mid-parse, which rejects an anchored entry, and that is the
1832
+ // safe direction (a click during parse takes the full-load path regardless).
1833
+ const liveKeys = new Set(
1834
+ String(liveKeysOverride != null ? liveKeysOverride : (buildHaveHeader() || ''))
1835
+ .split(',').filter(Boolean)
1836
+ );
1837
+ if (!liveKeys.has(anchor)) {
1838
+ prefetchCache.delete(key);
1839
+ return null;
1840
+ }
1841
+ }
1714
1842
  prefetchCache.delete(key);
1715
- if ((nowMs() - entry.at) >= PREFETCH_TTL) return null;
1716
1843
  return entry;
1717
1844
  }
1718
1845
 
@@ -1944,7 +2071,15 @@ function handleNavigationError(href, status, error) {
1944
2071
  // cross-document nav). Fall back to a hard load so an unrecoverable case
1945
2072
  // is not a silent dead-end. This is the exception, reached only after
1946
2073
  // the event was not cancelled AND no in-place target exists.
1947
- if (typeof location !== 'undefined') location.href = href;
2074
+ //
2075
+ // Report it like every other degradation (#1114): this IS a click turning
2076
+ // into a document load, so an app watching `webjs:navigation-fallback` to
2077
+ // count full loads must see it. The preceding `webjs:navigation-error`
2078
+ // carries no `cause` / `willReload`, so it is not a substitute.
2079
+ if (typeof location !== 'undefined') {
2080
+ reportFallback('navigation-error-unrecoverable', href);
2081
+ location.href = href;
2082
+ }
1948
2083
  }
1949
2084
 
1950
2085
  /**
@@ -1994,13 +2129,19 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
1994
2129
  const busyFrame = frameId ? markFrameBusy(frameId, myToken) : null;
1995
2130
  try {
1996
2131
  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;
2132
+ // Warm-cache fast path: a hover/focus/viewport prefetch may already hold
2133
+ // this page. Consume it instead of going to the network, so the click
2134
+ // resolves with no round-trip. Only for plain GET navs without a frame
2135
+ // target; form submissions and frame swaps always hit the server. The entry
2136
+ // is single-use (prefetchTake removes it), TTL-guarded, and validated by its
2137
+ // ANCHOR rather than by an identical X-Webjs-Have (#1114): a fragment
2138
+ // applies wherever the boundary it starts at is still live, so an unrelated
2139
+ // navigation between the prefetch and this click does not disqualify it.
2140
+ // The optimistic skeleton has already deleted nested boundaries by now, so
2141
+ // pass the view captured before it ran.
2142
+ const prefetched = (method === 'GET' && !body && !frameId)
2143
+ ? prefetchTake(href, optimisticState ? optimisticState.haveKeys : undefined)
2144
+ : null;
2004
2145
  if (prefetched) {
2005
2146
  html = prefetched.html;
2006
2147
  incomingBuild = prefetched.build;
@@ -2023,7 +2164,10 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
2023
2164
  }
2024
2165
 
2025
2166
  /** @type {RequestInit} */
2026
- const init = { method, headers, credentials: 'same-origin' };
2167
+ // `no-cache` for the same reason as the prefetch fetch (#1131): applySwap
2168
+ // hard-reloads on a build change it can only see if these headers are
2169
+ // live, not replayed from the HTTP cache.
2170
+ const init = { method, headers, credentials: 'same-origin', cache: 'no-cache' };
2027
2171
  if (signal) init.signal = signal;
2028
2172
  if (body != null && method !== 'GET' && method !== 'HEAD') init.body = body;
2029
2173
 
@@ -2753,14 +2897,17 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
2753
2897
  // user is on a stale importmap but at least the page
2754
2898
  // renders.
2755
2899
  sessionStorage.removeItem(flag);
2900
+ reportFallback('deploy-mismatch-reload-suppressed', href, false);
2756
2901
  } else {
2757
2902
  if (sessionStorage) sessionStorage.setItem(flag, '1');
2903
+ reportFallback('deploy-mismatch', href);
2758
2904
  location.href = href;
2759
2905
  return;
2760
2906
  }
2761
2907
  } catch {
2762
2908
  // sessionStorage unavailable (private mode w/ quota etc.):
2763
2909
  // fall through to a single reload like before.
2910
+ reportFallback('deploy-mismatch', href);
2764
2911
  location.href = href;
2765
2912
  return;
2766
2913
  }
@@ -2896,7 +3043,7 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
2896
3043
  // is already viewing a page, so a background op must never yank them
2897
3044
  // through a hard load; it takes the in-place path below.
2898
3045
  if (href && !revalidating && typeof location !== 'undefined') {
2899
- devWarnFallback(!here ? 'live-boundaries-malformed'
3046
+ reportFallback(!here ? 'live-boundaries-malformed'
2900
3047
  : !there ? 'incoming-boundaries-malformed'
2901
3048
  : 'no-shared-boundary', href);
2902
3049
  location.href = href;
@@ -2909,7 +3056,7 @@ function applySwap(doc, frameId, revalidating, href, incomingBuild, incomingSrc)
2909
3056
  // fragment, so the full-body swap below would wipe the shell from a
2910
3057
  // background op. Doing nothing is the only safe degradation here.
2911
3058
  if (href && revalidating) {
2912
- devWarnFallback('revalidation-discarded', href);
3059
+ reportFallback('revalidation-discarded', href, false);
2913
3060
  return 'discard';
2914
3061
  }
2915
3062
 
@@ -3586,6 +3733,16 @@ function applyOptimisticLoading() {
3586
3733
  if (deepest === null || tpl === null) return null;
3587
3734
 
3588
3735
  const slot = slots.get(deepest);
3736
+ // Snapshot the boundary keys BEFORE the skeleton wipes them (#1114). The
3737
+ // range below deletes everything between this slot's markers, which includes
3738
+ // every NESTED boundary comment, so `buildHaveHeader()` afterwards is
3739
+ // legitimately shorter than the page really is. `prefetchTake` validates a
3740
+ // cached fragment's anchor against the live boundaries, and without this it
3741
+ // would judge against the skeleton's truncated view: on an app whose only
3742
+ // loading.{js,ts} sits at the root, every deeper anchor vanishes and NO
3743
+ // prefetch is ever consumable. Carried on the state that already threads to
3744
+ // fetchAndApply, so nothing new has to be plumbed.
3745
+ const haveKeys = buildHaveHeader();
3589
3746
  /** @type {Node[]} */
3590
3747
  const oldChildren = [];
3591
3748
  for (let n = slot.start.nextSibling; n && n !== slot.end; n = n.nextSibling) {
@@ -3597,10 +3754,10 @@ function applyOptimisticLoading() {
3597
3754
  range.setEndBefore(slot.end);
3598
3755
  range.deleteContents();
3599
3756
  slot.start.parentNode.insertBefore(tpl.content.cloneNode(true), slot.end);
3600
- return { slot, oldChildren, token: currentNavigationToken };
3757
+ return { slot, oldChildren, token: currentNavigationToken, haveKeys };
3601
3758
  }
3602
3759
 
3603
- /** @param {{ slot: { start: Comment, end: Comment }, oldChildren: Node[], token: number } | null} state */
3760
+ /** @param {{ slot: { start: Comment, end: Comment }, oldChildren: Node[], token: number, haveKeys?: string } | null} state */
3604
3761
  function restoreOptimistic(state) {
3605
3762
  if (!state) return;
3606
3763
  // A newer nav superseded the one that captured this state: don't
@@ -4255,6 +4412,8 @@ export {
4255
4412
  prefetchHasHoverPointer as _prefetchHasHoverPointer,
4256
4413
  prefetch as _prefetch,
4257
4414
  prefetchTake as _prefetchTake,
4415
+ prefetchAnchor as _prefetchAnchor,
4416
+ applyOptimisticLoading as _applyOptimisticLoading,
4258
4417
  prefetchSaysSaveData as _prefetchSaysSaveData,
4259
4418
  readStreamedShell as _readStreamedShell,
4260
4419
  takeResolveUnit as _takeResolveUnit,