@webjsdev/core 0.7.3 → 0.7.4

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.3",
3
+ "version": "0.7.4",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
@@ -167,7 +167,20 @@ export function enableClientRouter() {
167
167
  document.addEventListener('click', onClick, false);
168
168
  document.addEventListener('submit', onSubmit, false);
169
169
  window.addEventListener('popstate', onPopState);
170
+ // Intent prefetch: warm the next page on hover / focus / touch-start.
171
+ // pointerover + focusin bubble, so one delegated listener each covers
172
+ // the whole document, including links added by later navigations.
173
+ document.addEventListener('pointerover', onPrefetchIntent, true);
174
+ document.addEventListener('focusin', onPrefetchIntent, true);
175
+ document.addEventListener('touchstart', onPrefetchIntent, { capture: true, passive: true });
176
+ document.addEventListener('pointerout', onPrefetchOut, true);
177
+ // After every client navigation the swapped-in DOM may carry new
178
+ // anchors, so re-scan for render/viewport modes. webjs:navigate fires
179
+ // at the end of fetchAndApply for both link and frame swaps.
180
+ document.addEventListener('webjs:navigate', refreshPrefetchObservers);
170
181
  ensureUpgradeObserver();
182
+ // Apply render/viewport prefetch modes to the initial document.
183
+ refreshPrefetchObservers();
171
184
  // Take control of scroll restoration so the browser doesn't fight
172
185
  // the SPA's own snapshot-based restore on popstate.
173
186
  if (typeof history !== 'undefined' && 'scrollRestoration' in history) {
@@ -186,6 +199,13 @@ export function disableClientRouter() {
186
199
  document.removeEventListener('click', onClick, false);
187
200
  document.removeEventListener('submit', onSubmit, false);
188
201
  window.removeEventListener('popstate', onPopState);
202
+ document.removeEventListener('pointerover', onPrefetchIntent, true);
203
+ document.removeEventListener('focusin', onPrefetchIntent, true);
204
+ document.removeEventListener('touchstart', onPrefetchIntent, /** @type any */ ({ capture: true }));
205
+ document.removeEventListener('pointerout', onPrefetchOut, true);
206
+ document.removeEventListener('webjs:navigate', refreshPrefetchObservers);
207
+ clearPrefetchHover();
208
+ if (prefetchViewObserver) { prefetchViewObserver.disconnect(); prefetchViewObserver = null; }
189
209
  if (typeof history !== 'undefined' && prevScrollRestoration !== null) {
190
210
  history.scrollRestoration = prevScrollRestoration;
191
211
  prevScrollRestoration = null;
@@ -211,6 +231,11 @@ export async function navigate(url, opts) {
211
231
  * Invalidate a cached snapshot. Call after a server action mutates data
212
232
  * that affects a cached page so the next visit refetches.
213
233
  *
234
+ * Evicts BOTH the back/forward snapshot cache and the speculative
235
+ * prefetch cache. A prefetched fragment captured before a mutation would
236
+ * otherwise be served stale on the next forward click, the same staleness
237
+ * the snapshot eviction prevents for back/forward.
238
+ *
214
239
  * @param {string} [url] Specific URL to invalidate, or omit to clear all.
215
240
  */
216
241
  export function revalidate(url) {
@@ -218,13 +243,20 @@ export function revalidate(url) {
218
243
  // Loose `== null` would have left `revalidate('')` to silently no-op,
219
244
  // because `new URL('', location.href)` is a valid relative URL and the
220
245
  // resulting cache key rarely matches anything.
221
- if (!url) { snapshotCache.clear(); return; }
246
+ if (!url) { snapshotCache.clear(); prefetchCache.clear(); return; }
222
247
  const u = new URL(url, location.href);
223
- snapshotCache.delete(u.pathname + u.search);
248
+ const key = u.pathname + u.search;
249
+ snapshotCache.delete(key);
250
+ prefetchCache.delete(key);
224
251
  }
225
252
 
226
- // Auto-enable on import (standard Turbo-Drive convention).
227
- enableClientRouter();
253
+ // Auto-enable on import: deferred to the END of this module (see the
254
+ // call after the test-only exports). enableClientRouter() transitively
255
+ // reads the prefetch state (prefetchViewObserver and the caches), which
256
+ // are `const`/`let` declared lower in the file and therefore in the
257
+ // temporal dead zone here. Calling enable at module-end, after every
258
+ // top-level binding is initialised, avoids a ReferenceError in the
259
+ // bundled browser build.
228
260
 
229
261
  /* ====================================================================
230
262
  * Click + popstate handlers
@@ -710,9 +742,12 @@ async function performSubmission(href, method, body, frameId) {
710
742
  );
711
743
  // Mutating submissions invalidate cached versions of other URLs -
712
744
  // do this *after* the response applies so the new page itself is
713
- // snapshotted on the next nav, not pre-emptively wiped.
745
+ // snapshotted on the next nav, not pre-emptively wiped. Clear the
746
+ // speculative prefetch cache too: a fragment prefetched before this
747
+ // mutation would otherwise be served stale on a later forward click.
714
748
  if (!isSafe && myToken === currentNavigationToken) {
715
749
  snapshotCache.clear();
750
+ prefetchCache.clear();
716
751
  }
717
752
  } finally {
718
753
  if (navigatingFlagTimer) clearTimeout(navigatingFlagTimer);
@@ -735,6 +770,375 @@ function buildHaveHeader() {
735
770
  return [...slots.keys()].join(',');
736
771
  }
737
772
 
773
+ /* ====================================================================
774
+ * Link prefetch (Remix-style strategies, fast-by-default)
775
+ *
776
+ * A link click already resolves through fetchAndApply, but the fetch
777
+ * only STARTS on click, so the user waits a full round-trip. Prefetch
778
+ * warms a dedicated cache speculatively so the click reads it instantly.
779
+ *
780
+ * Strategy per anchor via a `data-prefetch` attribute (valid-HTML data-*,
781
+ * like SvelteKit / Astro), defaulting to `intent` so the common case is
782
+ * fast without per-link opt-in, the way Next / Nuxt / SvelteKit ship
783
+ * auto-prefetch. Value vocabulary borrows Next's true/false/auto aliases:
784
+ * - intent (default) : hover / focus / touch, after a short dwell
785
+ * - true / render : eager, as soon as a document scan sees it
786
+ * - auto / viewport : on viewport entry (IntersectionObserver, 0.5)
787
+ * - false / none : never (also data-no-prefetch / rel="external")
788
+ *
789
+ * Why a separate cache, not snapshotCache: snapshotCache is keyed to the
790
+ * back/forward restore path (popstate), which holds the FULL serialized
791
+ * document of pages the user already visited. A prefetch holds the
792
+ * SERVER FRAGMENT for a page not yet visited (the same X-Webjs-Have
793
+ * partial body a real nav would receive). fetchAndApply consumes it via
794
+ * prefetchTake() before falling back to the network.
795
+ *
796
+ * Only same-origin in-app links are prefetched (the same eligibility as
797
+ * a click), and never under Save-Data / prefers-reduced-data. There is no
798
+ * logout-style heuristic: prefetch issues a real GET, so as everywhere in
799
+ * the ecosystem (Next / Nuxt / Remix), a non-idempotent action must be a
800
+ * POST or a `<form>`, and `data-no-prefetch` / `rel="external"` opt out.
801
+ *
802
+ * What we do NOT touch: a native `<link rel="prefetch">` in the document
803
+ * head is the browser's own mechanism and warms the HTTP cache; we never
804
+ * interfere with it.
805
+ * ==================================================================== */
806
+
807
+ /** Max speculative responses held at once (LRU). */
808
+ const PREFETCH_CAP = 8;
809
+ /** Speculative entries expire after this long (ms): avoid serving stale. */
810
+ const PREFETCH_TTL = 30_000;
811
+ /** Max concurrent in-flight prefetch requests. */
812
+ const PREFETCH_CONCURRENCY = 3;
813
+ /** Max prefetches waiting for a free slot (bounds a huge link list). */
814
+ const PREFETCH_QUEUE_CAP = 24;
815
+ /** Hover dwell before a prefetch fires (ms): filter drive-by pointer moves. Matches Remix's intent timeout. */
816
+ const PREFETCH_HOVER_DELAY = 100;
817
+
818
+ /** @typedef {{ html: string, build: string | null, finalUrl: string, at: number }} PrefetchEntry */
819
+ /** @type {Map<string, PrefetchEntry>} */
820
+ const prefetchCache = new Map();
821
+ /** Keys with a fetch currently in flight (dedupe + concurrency gate). */
822
+ const prefetchInflight = new Set();
823
+ /** hrefs waiting for a free concurrency slot (FIFO), and their keys. */
824
+ const prefetchQueue = [];
825
+ const prefetchQueued = new Set();
826
+ /** Pending hover-dwell timer, cleared on pointerout / blur. */
827
+ let prefetchHoverTimer = null;
828
+ /** Last anchor a hover timer was armed for (so pointerout can match). */
829
+ let prefetchHoverAnchor = null;
830
+ /** IntersectionObserver for data-prefetch="viewport" anchors, or null. */
831
+ let prefetchViewObserver = null;
832
+
833
+ /**
834
+ * True when the user or platform has asked us to conserve data. Both the
835
+ * Save-Data client hint and the prefers-reduced-data media query disable
836
+ * speculative fetching. Guarded for non-browser / partial DOM.
837
+ *
838
+ * @returns {boolean}
839
+ */
840
+ function prefetchSaysSaveData() {
841
+ try {
842
+ const c = typeof navigator !== 'undefined' ? /** @type any */ (navigator).connection : null;
843
+ if (c && c.saveData === true) return true;
844
+ if (typeof matchMedia === 'function' && matchMedia('(prefers-reduced-data: reduce)').matches) {
845
+ return true;
846
+ }
847
+ } catch { /* ignore */ }
848
+ return false;
849
+ }
850
+
851
+ /**
852
+ * Lowercased whitespace-separated rel tokens of an anchor.
853
+ * @param {Element} anchor
854
+ * @returns {string[]}
855
+ */
856
+ function relTokens(anchor) {
857
+ const rel = anchor.getAttribute('rel');
858
+ return rel ? rel.toLowerCase().split(/\s+/).filter(Boolean) : [];
859
+ }
860
+
861
+ /**
862
+ * Decide whether an anchor is a same-origin in-app target the router can
863
+ * navigate, returning its absolute href or null. Shared by onClick and
864
+ * the prefetch listeners so eligibility never drifts between them.
865
+ *
866
+ * @param {Element | null} anchor
867
+ * @returns {string | null}
868
+ */
869
+ function eligibleAnchorHref(anchor) {
870
+ if (!anchor || !(anchor instanceof HTMLAnchorElement)) return null;
871
+ if (anchor.hasAttribute('download')) return null;
872
+ if (anchor.hasAttribute('data-no-router')) return null;
873
+ if (anchor.target && anchor.target !== '_self') return null;
874
+ const href = anchor.href;
875
+ if (!href) return null;
876
+ let url;
877
+ try { url = new URL(href); } catch { return null; }
878
+ if (url.origin !== location.origin) return null;
879
+ // A pure same-page hash jump is not a navigation we fetch.
880
+ if (url.pathname === location.pathname && url.search === location.search && url.hash) return null;
881
+ if (NON_HTML_EXTENSIONS.test(url.pathname)) return null;
882
+ return href;
883
+ }
884
+
885
+ /**
886
+ * Whether prefetching this anchor is suppressed by author intent. The
887
+ * `external` rel marks a link leaving the app, `no-prefetch` and
888
+ * `data-no-prefetch` are explicit opt-outs, and `data-no-router` already
889
+ * disables routing entirely (so it is caught upstream too).
890
+ *
891
+ * @param {Element} anchor
892
+ * @returns {boolean}
893
+ */
894
+ function prefetchSuppressed(anchor) {
895
+ if (anchor.hasAttribute('data-no-prefetch')) return true;
896
+ const rel = relTokens(anchor);
897
+ return rel.includes('external') || rel.includes('no-prefetch');
898
+ }
899
+
900
+ /**
901
+ * Resolve the prefetch strategy for an anchor from a `data-prefetch`
902
+ * attribute. webjs has no Link component (links are plain `<a href>`), so
903
+ * the knob is a valid-HTML `data-*` attribute, the same shape SvelteKit
904
+ * (`data-sveltekit-preload-data`) and Astro (`data-astro-prefetch`) use.
905
+ * Next.js / Nuxt / Remix express the same choice as a component PROP
906
+ * (`<Link prefetch>`) that never reaches the DOM, so there is nothing to
907
+ * mirror attribute-wise; we reuse their value vocabulary (true/false/auto)
908
+ * as aliases. Default is `intent` (fast-by-default) when the attribute is
909
+ * absent or unrecognised.
910
+ *
911
+ * Value mapping (case-insensitive):
912
+ * - absent / unknown : `intent` (the default)
913
+ * - `intent` : hover / focus / touch, after a short dwell
914
+ * - `true` / `render` : eager, as soon as a document scan sees the link
915
+ * - `auto` / `viewport`: on viewport entry (IntersectionObserver)
916
+ * - `false` / `none` : never (also via data-no-prefetch / rel="external")
917
+ *
918
+ * Returns `none` for suppressed anchors so callers have a single check.
919
+ *
920
+ * @param {Element} anchor
921
+ * @returns {'intent' | 'render' | 'viewport' | 'none'}
922
+ */
923
+ function prefetchMode(anchor) {
924
+ if (prefetchSuppressed(anchor)) return 'none';
925
+ const raw = (anchor.getAttribute('data-prefetch') || '').toLowerCase().trim();
926
+ switch (raw) {
927
+ case 'false':
928
+ case 'none':
929
+ return 'none';
930
+ case 'true':
931
+ case 'render':
932
+ return 'render';
933
+ case 'auto':
934
+ case 'viewport':
935
+ return 'viewport';
936
+ case 'intent':
937
+ return 'intent';
938
+ default:
939
+ // Unset or unrecognised value: the fast default.
940
+ return 'intent';
941
+ }
942
+ }
943
+
944
+ /**
945
+ * Speculatively fetch `href` and stash the server fragment so a later
946
+ * click resolves instantly. No-op when data-saving is on or the entry is
947
+ * already cached or in flight. When the concurrency gate is full the
948
+ * request is QUEUED (not dropped) and drains as in-flight slots free, so
949
+ * a burst of `render` / `viewport` links all eventually prefetch rather
950
+ * than silently losing everything past the cap.
951
+ *
952
+ * @param {string} href
953
+ */
954
+ function prefetch(href) {
955
+ if (typeof fetch !== 'function') return;
956
+ if (prefetchSaysSaveData()) return;
957
+ const key = cacheKey(href);
958
+ if (prefetchInflight.has(key)) return;
959
+ if (prefetchQueued.has(key)) return;
960
+ const existing = prefetchCache.get(key);
961
+ if (existing && (nowMs() - existing.at) < PREFETCH_TTL) return;
962
+ if (prefetchInflight.size >= PREFETCH_CONCURRENCY) {
963
+ // Gate full: queue rather than drop, bounded so a huge link list
964
+ // cannot grow the queue without limit (oldest queued entry is shed).
965
+ prefetchQueued.add(key);
966
+ prefetchQueue.push(href);
967
+ while (prefetchQueue.length > PREFETCH_QUEUE_CAP) {
968
+ const dropped = prefetchQueue.shift();
969
+ prefetchQueued.delete(cacheKey(dropped));
970
+ }
971
+ return;
972
+ }
973
+
974
+ prefetchInflight.add(key);
975
+ const headers = { 'x-webjs-router': '1', 'x-webjs-prefetch': '1' };
976
+ const have = buildHaveHeader();
977
+ if (have) headers['x-webjs-have'] = have;
978
+
979
+ fetch(href, { method: 'GET', headers, credentials: 'same-origin' })
980
+ .then(async (resp) => {
981
+ const ctype = resp.headers.get('content-type') || '';
982
+ if (!/^text\/html\b/i.test(ctype)) return;
983
+ if (resp.status >= 400) return;
984
+ const build = resp.headers.get('x-webjs-build');
985
+ const finalUrl = resp.redirected && resp.url ? resp.url : href;
986
+ const html = await resp.text();
987
+ prefetchStore(key, { html, build, finalUrl, at: nowMs() });
988
+ })
989
+ .catch(() => { /* speculative: swallow */ })
990
+ .finally(() => {
991
+ prefetchInflight.delete(key);
992
+ drainPrefetchQueue();
993
+ });
994
+ }
995
+
996
+ /** Start the next queued prefetch if a concurrency slot is free. */
997
+ function drainPrefetchQueue() {
998
+ while (prefetchQueue.length && prefetchInflight.size < PREFETCH_CONCURRENCY) {
999
+ const href = prefetchQueue.shift();
1000
+ prefetchQueued.delete(cacheKey(href));
1001
+ prefetch(href);
1002
+ }
1003
+ }
1004
+
1005
+ /**
1006
+ * Store a speculative entry under LRU + cap.
1007
+ * @param {string} key
1008
+ * @param {PrefetchEntry} entry
1009
+ */
1010
+ function prefetchStore(key, entry) {
1011
+ if (prefetchCache.has(key)) prefetchCache.delete(key);
1012
+ prefetchCache.set(key, entry);
1013
+ while (prefetchCache.size > PREFETCH_CAP) {
1014
+ const oldest = prefetchCache.keys().next().value;
1015
+ prefetchCache.delete(oldest);
1016
+ }
1017
+ }
1018
+
1019
+ /**
1020
+ * Consume a fresh speculative entry for `href`, removing it (a fragment
1021
+ * is single-use: once applied it becomes a real snapshot). Returns null
1022
+ * on miss or when the entry has aged past the TTL.
1023
+ *
1024
+ * @param {string} href
1025
+ * @returns {PrefetchEntry | null}
1026
+ */
1027
+ function prefetchTake(href) {
1028
+ const key = cacheKey(href);
1029
+ const entry = prefetchCache.get(key);
1030
+ if (!entry) return null;
1031
+ prefetchCache.delete(key);
1032
+ if ((nowMs() - entry.at) >= PREFETCH_TTL) return null;
1033
+ return entry;
1034
+ }
1035
+
1036
+ /** Monotonic-ish clock guarded for environments without performance. */
1037
+ function nowMs() {
1038
+ try {
1039
+ if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
1040
+ return performance.now();
1041
+ }
1042
+ } catch { /* ignore */ }
1043
+ return 0;
1044
+ }
1045
+
1046
+ /** @param {Event} e */
1047
+ function onPrefetchIntent(e) {
1048
+ if (!enabled) return;
1049
+ const anchor = closestAnchor(/** @type any */ (e.target));
1050
+ if (!anchor) return;
1051
+ // Only `intent` links prefetch on hover/focus/touch. `render` and
1052
+ // `viewport` links are handled by the document scan / observer, and
1053
+ // `none` is suppressed.
1054
+ if (prefetchMode(anchor) !== 'intent') return;
1055
+ const href = eligibleAnchorHref(anchor);
1056
+ if (!href) return;
1057
+ // pointerover/focusin bubble, so re-entering a child of the same anchor
1058
+ // would re-arm; collapse to one timer per anchor.
1059
+ if (prefetchHoverAnchor === anchor && prefetchHoverTimer) return;
1060
+ clearPrefetchHover();
1061
+ prefetchHoverAnchor = anchor;
1062
+ prefetchHoverTimer = setTimeout(() => {
1063
+ prefetchHoverTimer = null;
1064
+ prefetchHoverAnchor = null;
1065
+ prefetch(href);
1066
+ }, PREFETCH_HOVER_DELAY);
1067
+ }
1068
+
1069
+ /** @param {Event} e */
1070
+ function onPrefetchOut(e) {
1071
+ const anchor = closestAnchor(/** @type any */ (e.target));
1072
+ if (anchor && anchor === prefetchHoverAnchor) clearPrefetchHover();
1073
+ }
1074
+
1075
+ function clearPrefetchHover() {
1076
+ if (prefetchHoverTimer) { clearTimeout(prefetchHoverTimer); prefetchHoverTimer = null; }
1077
+ prefetchHoverAnchor = null;
1078
+ }
1079
+
1080
+ /**
1081
+ * Nearest enclosing <a>, crossing shadow boundaries, from an event
1082
+ * target. composedPath is click-only, so walk getRootNode().host here.
1083
+ *
1084
+ * @param {EventTarget | null} target
1085
+ * @returns {HTMLAnchorElement | null}
1086
+ */
1087
+ function closestAnchor(target) {
1088
+ let node = /** @type {Node | null} */ (target);
1089
+ while (node) {
1090
+ if (node instanceof HTMLAnchorElement) return node;
1091
+ const el = node.nodeType === 1 ? /** @type {Element} */ (node) : null;
1092
+ if (el) {
1093
+ const a = el.closest && el.closest('a');
1094
+ if (a instanceof HTMLAnchorElement) return a;
1095
+ }
1096
+ const root = node.getRootNode ? node.getRootNode() : null;
1097
+ node = root && /** @type any */ (root).host ? /** @type any */ (root).host : null;
1098
+ }
1099
+ return null;
1100
+ }
1101
+
1102
+ /**
1103
+ * (Re)scan the document and apply the non-hover prefetch modes:
1104
+ * - `render` anchors prefetch immediately (they are now in the DOM).
1105
+ * - `viewport` anchors are observed and prefetch on intersection.
1106
+ * `intent` (the default) is handled by the hover/focus/touch listeners,
1107
+ * and `none` is skipped. Called on enable and after each navigation,
1108
+ * since the swapped-in DOM may carry new links.
1109
+ *
1110
+ * The viewport threshold (0.5) matches Remix's IntersectionObserver.
1111
+ */
1112
+ function refreshPrefetchObservers() {
1113
+ if (typeof document === 'undefined') return;
1114
+ if (prefetchSaysSaveData()) return;
1115
+ const hasIO = typeof IntersectionObserver !== 'undefined';
1116
+ if (hasIO) {
1117
+ if (!prefetchViewObserver) {
1118
+ prefetchViewObserver = new IntersectionObserver((entries) => {
1119
+ for (const entry of entries) {
1120
+ if (!entry.isIntersecting) continue;
1121
+ const anchor = /** @type {Element} */ (entry.target);
1122
+ prefetchViewObserver.unobserve(anchor);
1123
+ const href = eligibleAnchorHref(anchor);
1124
+ if (href && prefetchMode(anchor) === 'viewport') prefetch(href);
1125
+ }
1126
+ }, { threshold: 0.5 });
1127
+ } else {
1128
+ prefetchViewObserver.disconnect();
1129
+ }
1130
+ }
1131
+ for (const anchor of document.querySelectorAll('a[href]')) {
1132
+ const mode = prefetchMode(anchor);
1133
+ if (mode === 'render') {
1134
+ const href = eligibleAnchorHref(anchor);
1135
+ if (href) prefetch(href);
1136
+ } else if (mode === 'viewport' && hasIO) {
1137
+ prefetchViewObserver.observe(anchor);
1138
+ }
1139
+ }
1140
+ }
1141
+
738
1142
  /**
739
1143
  * Fetch the target URL and apply the swap.
740
1144
  *
@@ -755,6 +1159,18 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
755
1159
  /** @type {string} */
756
1160
  let finalUrl = href;
757
1161
  try {
1162
+ // Warm-cache fast path: a hover/focus/viewport prefetch may have
1163
+ // already fetched this exact page (same X-Webjs-Have shell). Consume
1164
+ // it instead of going to the network, so the click resolves with no
1165
+ // round-trip. Only for plain GET navs without a frame target; form
1166
+ // submissions and frame swaps always hit the server. The entry is
1167
+ // single-use (prefetchTake removes it) and TTL-guarded inside take.
1168
+ const prefetched = (method === 'GET' && !body && !frameId) ? prefetchTake(href) : null;
1169
+ if (prefetched) {
1170
+ html = prefetched.html;
1171
+ incomingBuild = prefetched.build;
1172
+ finalUrl = prefetched.finalUrl;
1173
+ } else {
758
1174
  const headers = { 'x-webjs-router': '1' };
759
1175
  const have = buildHaveHeader();
760
1176
  if (have) headers['x-webjs-have'] = have;
@@ -807,6 +1223,7 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
807
1223
  // this to detect deploys that bumped the vendor pin.
808
1224
  incomingBuild = resp.headers.get('x-webjs-build');
809
1225
  html = await resp.text();
1226
+ }
810
1227
  } catch (err) {
811
1228
  // Aborted by a newer navigation: let it run, don't fall back.
812
1229
  if (err && /** @type any */ (err).name === 'AbortError') return;
@@ -1698,8 +2115,27 @@ export {
1698
2115
  getSubmitAction as _getSubmitAction,
1699
2116
  buildSubmitFormData as _buildSubmitFormData,
1700
2117
  restoreOptimistic as _restoreOptimistic,
2118
+ eligibleAnchorHref as _eligibleAnchorHref,
2119
+ prefetchSuppressed as _prefetchSuppressed,
2120
+ prefetchMode as _prefetchMode,
2121
+ prefetch as _prefetch,
2122
+ prefetchTake as _prefetchTake,
2123
+ prefetchSaysSaveData as _prefetchSaysSaveData,
1701
2124
  };
1702
2125
 
2126
+ /** Test-only: peek the speculative cache for a href without consuming it. */
2127
+ export function _prefetchPeek(href) { return prefetchCache.get(cacheKey(href)) || null; }
2128
+ /** Test-only: number of prefetch requests currently in flight. */
2129
+ export function _prefetchInflightSize() { return prefetchInflight.size; }
2130
+ /** Test-only: clear all prefetch state between cases. */
2131
+ export function _resetPrefetch() {
2132
+ prefetchCache.clear();
2133
+ prefetchInflight.clear();
2134
+ prefetchQueue.length = 0;
2135
+ prefetchQueued.clear();
2136
+ clearPrefetchHover();
2137
+ }
2138
+
1703
2139
  /** Test-only: read the monotonic navigation-token counter. */
1704
2140
  export function _navToken() { return currentNavigationToken; }
1705
2141
  /** Test-only: bump the navigation-token counter (simulates a fresh nav). */
@@ -1719,3 +2155,8 @@ export function _setCurrentPageUrl(u) { currentPageUrl = u; }
1719
2155
  export function _isNonHtmlPath(pathname) {
1720
2156
  return NON_HTML_EXTENSIONS.test(pathname);
1721
2157
  }
2158
+
2159
+ // Auto-enable on import (standard Turbo-Drive convention). Placed last so
2160
+ // every top-level binding the router touches (notably the prefetch state)
2161
+ // is initialised before enableClientRouter() runs.
2162
+ enableClientRouter();