@webjsdev/core 0.7.2 → 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.2",
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",
@@ -150,10 +150,37 @@ 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);
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);
156
181
  ensureUpgradeObserver();
182
+ // Apply render/viewport prefetch modes to the initial document.
183
+ refreshPrefetchObservers();
157
184
  // Take control of scroll restoration so the browser doesn't fight
158
185
  // the SPA's own snapshot-based restore on popstate.
159
186
  if (typeof history !== 'undefined' && 'scrollRestoration' in history) {
@@ -169,9 +196,16 @@ export function enableClientRouter() {
169
196
  export function disableClientRouter() {
170
197
  if (!enabled) return;
171
198
  enabled = false;
172
- document.removeEventListener('click', onClick, true);
173
- document.removeEventListener('submit', onSubmit, true);
199
+ document.removeEventListener('click', onClick, false);
200
+ document.removeEventListener('submit', onSubmit, false);
174
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; }
175
209
  if (typeof history !== 'undefined' && prevScrollRestoration !== null) {
176
210
  history.scrollRestoration = prevScrollRestoration;
177
211
  prevScrollRestoration = null;
@@ -197,6 +231,11 @@ export async function navigate(url, opts) {
197
231
  * Invalidate a cached snapshot. Call after a server action mutates data
198
232
  * that affects a cached page so the next visit refetches.
199
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
+ *
200
239
  * @param {string} [url] Specific URL to invalidate, or omit to clear all.
201
240
  */
202
241
  export function revalidate(url) {
@@ -204,13 +243,20 @@ export function revalidate(url) {
204
243
  // Loose `== null` would have left `revalidate('')` to silently no-op,
205
244
  // because `new URL('', location.href)` is a valid relative URL and the
206
245
  // resulting cache key rarely matches anything.
207
- if (!url) { snapshotCache.clear(); return; }
246
+ if (!url) { snapshotCache.clear(); prefetchCache.clear(); return; }
208
247
  const u = new URL(url, location.href);
209
- snapshotCache.delete(u.pathname + u.search);
248
+ const key = u.pathname + u.search;
249
+ snapshotCache.delete(key);
250
+ prefetchCache.delete(key);
210
251
  }
211
252
 
212
- // Auto-enable on import (standard Turbo-Drive convention).
213
- 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.
214
260
 
215
261
  /* ====================================================================
216
262
  * Click + popstate handlers
@@ -257,11 +303,14 @@ function onPopState(_e) {
257
303
  }
258
304
 
259
305
  /**
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).
306
+ * Intercept form submissions. BUBBLE phase (see enableClientRouter) so we run
307
+ * AFTER a component's per-element `@submit` handler, which is bound at-target.
308
+ * That ordering is what makes the `if (e.defaultPrevented) return` guard below
309
+ * work: a component that calls `e.preventDefault()` (the chat / comments forms,
310
+ * or any JS-handled form) has already run, so we see the prevented default and
311
+ * leave the form alone. A capture listener would fire us first, before the
312
+ * component, defeating the guard and wrongly navigating the page out from under
313
+ * a JS-handled form.
265
314
  *
266
315
  * Filtering mirrors Turbo's `form_submit_observer.js`:
267
316
  * - `data-no-router` on form or submitter → full browser submit.
@@ -693,9 +742,12 @@ async function performSubmission(href, method, body, frameId) {
693
742
  );
694
743
  // Mutating submissions invalidate cached versions of other URLs -
695
744
  // do this *after* the response applies so the new page itself is
696
- // 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.
697
748
  if (!isSafe && myToken === currentNavigationToken) {
698
749
  snapshotCache.clear();
750
+ prefetchCache.clear();
699
751
  }
700
752
  } finally {
701
753
  if (navigatingFlagTimer) clearTimeout(navigatingFlagTimer);
@@ -718,6 +770,375 @@ function buildHaveHeader() {
718
770
  return [...slots.keys()].join(',');
719
771
  }
720
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
+
721
1142
  /**
722
1143
  * Fetch the target URL and apply the swap.
723
1144
  *
@@ -738,6 +1159,18 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
738
1159
  /** @type {string} */
739
1160
  let finalUrl = href;
740
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 {
741
1174
  const headers = { 'x-webjs-router': '1' };
742
1175
  const have = buildHaveHeader();
743
1176
  if (have) headers['x-webjs-have'] = have;
@@ -790,6 +1223,7 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
790
1223
  // this to detect deploys that bumped the vendor pin.
791
1224
  incomingBuild = resp.headers.get('x-webjs-build');
792
1225
  html = await resp.text();
1226
+ }
793
1227
  } catch (err) {
794
1228
  // Aborted by a newer navigation: let it run, don't fall back.
795
1229
  if (err && /** @type any */ (err).name === 'AbortError') return;
@@ -1681,8 +2115,27 @@ export {
1681
2115
  getSubmitAction as _getSubmitAction,
1682
2116
  buildSubmitFormData as _buildSubmitFormData,
1683
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,
1684
2124
  };
1685
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
+
1686
2139
  /** Test-only: read the monotonic navigation-token counter. */
1687
2140
  export function _navToken() { return currentNavigationToken; }
1688
2141
  /** Test-only: bump the navigation-token counter (simulates a fresh nav). */
@@ -1702,3 +2155,8 @@ export function _setCurrentPageUrl(u) { currentPageUrl = u; }
1702
2155
  export function _isNonHtmlPath(pathname) {
1703
2156
  return NON_HTML_EXTENSIONS.test(pathname);
1704
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();