@webjsdev/core 0.7.3 → 0.7.5

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.5",
4
4
  "type": "module",
5
5
  "description": "webjs core runtime - html/css tags, WebComponent base, isomorphic renderers",
6
6
  "types": "./index.d.ts",
@@ -314,6 +314,41 @@ async function renderTemplate(tr, ctx) {
314
314
  return out;
315
315
  }
316
316
 
317
+ // Browser-only names whose absence during SSR produces a recognisable error.
318
+ // Mirrors the `no-browser-globals-in-render` webjs check rule, which catches
319
+ // these at edit time; this turns the runtime SSR crash into the same guidance.
320
+ const SSR_BROWSER_GLOBALS = new Set([
321
+ 'document', 'window', 'localStorage', 'sessionStorage', 'navigator',
322
+ 'matchMedia', 'requestAnimationFrame', 'getComputedStyle',
323
+ 'IntersectionObserver', 'MutationObserver', 'ResizeObserver',
324
+ ]);
325
+ const SSR_HTMLELEMENT_METHODS = new Set([
326
+ 'attachShadow', 'setAttribute', 'getAttribute', 'removeAttribute',
327
+ 'hasAttribute', 'dispatchEvent', 'querySelector', 'querySelectorAll',
328
+ 'getBoundingClientRect', 'focus', 'blur', 'scrollIntoView',
329
+ ]);
330
+
331
+ /**
332
+ * If `e` is the recognisable failure of touching a browser-only API during
333
+ * SSR (a `ReferenceError` for a browser global, or a `TypeError` calling an
334
+ * HTMLElement method that does not exist on the bare server-side instance),
335
+ * return an actionable, member-naming hint; otherwise null.
336
+ * @param {unknown} e
337
+ * @returns {string | null}
338
+ */
339
+ function browserMemberHint(e) {
340
+ const msg = e && typeof (/** @type any */ (e).message) === 'string' ? /** @type any */ (e).message : '';
341
+ let m = /^(\w+) is not defined$/.exec(msg);
342
+ if (e instanceof ReferenceError && m && SSR_BROWSER_GLOBALS.has(m[1])) {
343
+ return `\`${m[1]}\` is a browser-only global and is undefined during SSR.`;
344
+ }
345
+ m = /\.(\w+) is not a function$/.exec(msg);
346
+ if (e instanceof TypeError && m && SSR_HTMLELEMENT_METHODS.has(m[1])) {
347
+ return `\`${m[1]}\` is an HTMLElement method that does not exist on the server-side component instance during SSR.`;
348
+ }
349
+ return null;
350
+ }
351
+
317
352
  /**
318
353
  * Scan an HTML string for registered custom elements and inject
319
354
  * Declarative Shadow DOM (`<template shadowrootmode="open">`).
@@ -443,7 +478,15 @@ async function injectDSD(html, ctx) {
443
478
  });
444
479
  }
445
480
  } catch (e) {
446
- console.error(`[webjs] SSR failed for <${tag}>:`, e);
481
+ const hint = browserMemberHint(e);
482
+ if (hint) {
483
+ console.error(
484
+ `[webjs] SSR failed for <${tag}>: ${hint} It was touched in the component's constructor or render(), which run during SSR. Move browser-only work to connectedCallback() or a lifecycle hook (firstUpdated/updated), which SSR never calls; seed first-paint defaults in the constructor only from server-known inputs (attributes / props).`,
485
+ e,
486
+ );
487
+ } else {
488
+ console.error(`[webjs] SSR failed for <${tag}>:`, e);
489
+ }
447
490
  }
448
491
  }
449
492
  if (!edits.length) return html;
@@ -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,390 @@ 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, then announce that the
1007
+ * fragment is cached and consumable.
1008
+ *
1009
+ * The `webjs:prefetch` event fires the instant a speculative fragment
1010
+ * becomes consumable (after the response body has been read), which is
1011
+ * strictly later than the prefetch request going out. App code can
1012
+ * listen to instrument prefetch hit rate, and tests can await it to know
1013
+ * a subsequent click will consume the cache rather than refetch. The
1014
+ * detail carries the cached URL and a `from: 'prefetch'` tag so a single
1015
+ * listener can disambiguate it from `webjs:navigate`.
1016
+ *
1017
+ * @param {string} key
1018
+ * @param {PrefetchEntry} entry
1019
+ */
1020
+ function prefetchStore(key, entry) {
1021
+ if (prefetchCache.has(key)) prefetchCache.delete(key);
1022
+ prefetchCache.set(key, entry);
1023
+ while (prefetchCache.size > PREFETCH_CAP) {
1024
+ const oldest = prefetchCache.keys().next().value;
1025
+ prefetchCache.delete(oldest);
1026
+ }
1027
+ if (typeof document !== 'undefined') {
1028
+ document.dispatchEvent(new CustomEvent('webjs:prefetch', {
1029
+ detail: { url: entry.finalUrl, key, from: 'prefetch' },
1030
+ }));
1031
+ }
1032
+ }
1033
+
1034
+ /**
1035
+ * Consume a fresh speculative entry for `href`, removing it (a fragment
1036
+ * is single-use: once applied it becomes a real snapshot). Returns null
1037
+ * on miss or when the entry has aged past the TTL.
1038
+ *
1039
+ * @param {string} href
1040
+ * @returns {PrefetchEntry | null}
1041
+ */
1042
+ function prefetchTake(href) {
1043
+ const key = cacheKey(href);
1044
+ const entry = prefetchCache.get(key);
1045
+ if (!entry) return null;
1046
+ prefetchCache.delete(key);
1047
+ if ((nowMs() - entry.at) >= PREFETCH_TTL) return null;
1048
+ return entry;
1049
+ }
1050
+
1051
+ /** Monotonic-ish clock guarded for environments without performance. */
1052
+ function nowMs() {
1053
+ try {
1054
+ if (typeof performance !== 'undefined' && typeof performance.now === 'function') {
1055
+ return performance.now();
1056
+ }
1057
+ } catch { /* ignore */ }
1058
+ return 0;
1059
+ }
1060
+
1061
+ /** @param {Event} e */
1062
+ function onPrefetchIntent(e) {
1063
+ if (!enabled) return;
1064
+ const anchor = closestAnchor(/** @type any */ (e.target));
1065
+ if (!anchor) return;
1066
+ // Only `intent` links prefetch on hover/focus/touch. `render` and
1067
+ // `viewport` links are handled by the document scan / observer, and
1068
+ // `none` is suppressed.
1069
+ if (prefetchMode(anchor) !== 'intent') return;
1070
+ const href = eligibleAnchorHref(anchor);
1071
+ if (!href) return;
1072
+ // pointerover/focusin bubble, so re-entering a child of the same anchor
1073
+ // would re-arm; collapse to one timer per anchor.
1074
+ if (prefetchHoverAnchor === anchor && prefetchHoverTimer) return;
1075
+ clearPrefetchHover();
1076
+ prefetchHoverAnchor = anchor;
1077
+ prefetchHoverTimer = setTimeout(() => {
1078
+ prefetchHoverTimer = null;
1079
+ prefetchHoverAnchor = null;
1080
+ prefetch(href);
1081
+ }, PREFETCH_HOVER_DELAY);
1082
+ }
1083
+
1084
+ /** @param {Event} e */
1085
+ function onPrefetchOut(e) {
1086
+ const anchor = closestAnchor(/** @type any */ (e.target));
1087
+ if (anchor && anchor === prefetchHoverAnchor) clearPrefetchHover();
1088
+ }
1089
+
1090
+ function clearPrefetchHover() {
1091
+ if (prefetchHoverTimer) { clearTimeout(prefetchHoverTimer); prefetchHoverTimer = null; }
1092
+ prefetchHoverAnchor = null;
1093
+ }
1094
+
1095
+ /**
1096
+ * Nearest enclosing <a>, crossing shadow boundaries, from an event
1097
+ * target. composedPath is click-only, so walk getRootNode().host here.
1098
+ *
1099
+ * @param {EventTarget | null} target
1100
+ * @returns {HTMLAnchorElement | null}
1101
+ */
1102
+ function closestAnchor(target) {
1103
+ let node = /** @type {Node | null} */ (target);
1104
+ while (node) {
1105
+ if (node instanceof HTMLAnchorElement) return node;
1106
+ const el = node.nodeType === 1 ? /** @type {Element} */ (node) : null;
1107
+ if (el) {
1108
+ const a = el.closest && el.closest('a');
1109
+ if (a instanceof HTMLAnchorElement) return a;
1110
+ }
1111
+ const root = node.getRootNode ? node.getRootNode() : null;
1112
+ node = root && /** @type any */ (root).host ? /** @type any */ (root).host : null;
1113
+ }
1114
+ return null;
1115
+ }
1116
+
1117
+ /**
1118
+ * (Re)scan the document and apply the non-hover prefetch modes:
1119
+ * - `render` anchors prefetch immediately (they are now in the DOM).
1120
+ * - `viewport` anchors are observed and prefetch on intersection.
1121
+ * `intent` (the default) is handled by the hover/focus/touch listeners,
1122
+ * and `none` is skipped. Called on enable and after each navigation,
1123
+ * since the swapped-in DOM may carry new links.
1124
+ *
1125
+ * The viewport threshold (0.5) matches Remix's IntersectionObserver.
1126
+ */
1127
+ function refreshPrefetchObservers() {
1128
+ if (typeof document === 'undefined') return;
1129
+ if (prefetchSaysSaveData()) return;
1130
+ const hasIO = typeof IntersectionObserver !== 'undefined';
1131
+ if (hasIO) {
1132
+ if (!prefetchViewObserver) {
1133
+ prefetchViewObserver = new IntersectionObserver((entries) => {
1134
+ for (const entry of entries) {
1135
+ if (!entry.isIntersecting) continue;
1136
+ const anchor = /** @type {Element} */ (entry.target);
1137
+ prefetchViewObserver.unobserve(anchor);
1138
+ const href = eligibleAnchorHref(anchor);
1139
+ if (href && prefetchMode(anchor) === 'viewport') prefetch(href);
1140
+ }
1141
+ }, { threshold: 0.5 });
1142
+ } else {
1143
+ prefetchViewObserver.disconnect();
1144
+ }
1145
+ }
1146
+ for (const anchor of document.querySelectorAll('a[href]')) {
1147
+ const mode = prefetchMode(anchor);
1148
+ if (mode === 'render') {
1149
+ const href = eligibleAnchorHref(anchor);
1150
+ if (href) prefetch(href);
1151
+ } else if (mode === 'viewport' && hasIO) {
1152
+ prefetchViewObserver.observe(anchor);
1153
+ }
1154
+ }
1155
+ }
1156
+
738
1157
  /**
739
1158
  * Fetch the target URL and apply the swap.
740
1159
  *
@@ -755,6 +1174,18 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
755
1174
  /** @type {string} */
756
1175
  let finalUrl = href;
757
1176
  try {
1177
+ // Warm-cache fast path: a hover/focus/viewport prefetch may have
1178
+ // already fetched this exact page (same X-Webjs-Have shell). Consume
1179
+ // it instead of going to the network, so the click resolves with no
1180
+ // round-trip. Only for plain GET navs without a frame target; form
1181
+ // submissions and frame swaps always hit the server. The entry is
1182
+ // single-use (prefetchTake removes it) and TTL-guarded inside take.
1183
+ const prefetched = (method === 'GET' && !body && !frameId) ? prefetchTake(href) : null;
1184
+ if (prefetched) {
1185
+ html = prefetched.html;
1186
+ incomingBuild = prefetched.build;
1187
+ finalUrl = prefetched.finalUrl;
1188
+ } else {
758
1189
  const headers = { 'x-webjs-router': '1' };
759
1190
  const have = buildHaveHeader();
760
1191
  if (have) headers['x-webjs-have'] = have;
@@ -807,6 +1238,7 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
807
1238
  // this to detect deploys that bumped the vendor pin.
808
1239
  incomingBuild = resp.headers.get('x-webjs-build');
809
1240
  html = await resp.text();
1241
+ }
810
1242
  } catch (err) {
811
1243
  // Aborted by a newer navigation: let it run, don't fall back.
812
1244
  if (err && /** @type any */ (err).name === 'AbortError') return;
@@ -850,7 +1282,7 @@ async function fetchAndApply(href, frameId, recordHistory, optimisticState, meth
850
1282
  }
851
1283
  }
852
1284
 
853
- document.dispatchEvent(new CustomEvent('webjs:navigate', { detail: { url: finalUrl, frameId } }));
1285
+ document.dispatchEvent(new CustomEvent('webjs:navigate', { detail: { url: finalUrl, frameId, from: 'navigate' } }));
854
1286
  }
855
1287
 
856
1288
  /**
@@ -1698,8 +2130,27 @@ export {
1698
2130
  getSubmitAction as _getSubmitAction,
1699
2131
  buildSubmitFormData as _buildSubmitFormData,
1700
2132
  restoreOptimistic as _restoreOptimistic,
2133
+ eligibleAnchorHref as _eligibleAnchorHref,
2134
+ prefetchSuppressed as _prefetchSuppressed,
2135
+ prefetchMode as _prefetchMode,
2136
+ prefetch as _prefetch,
2137
+ prefetchTake as _prefetchTake,
2138
+ prefetchSaysSaveData as _prefetchSaysSaveData,
1701
2139
  };
1702
2140
 
2141
+ /** Test-only: peek the speculative cache for a href without consuming it. */
2142
+ export function _prefetchPeek(href) { return prefetchCache.get(cacheKey(href)) || null; }
2143
+ /** Test-only: number of prefetch requests currently in flight. */
2144
+ export function _prefetchInflightSize() { return prefetchInflight.size; }
2145
+ /** Test-only: clear all prefetch state between cases. */
2146
+ export function _resetPrefetch() {
2147
+ prefetchCache.clear();
2148
+ prefetchInflight.clear();
2149
+ prefetchQueue.length = 0;
2150
+ prefetchQueued.clear();
2151
+ clearPrefetchHover();
2152
+ }
2153
+
1703
2154
  /** Test-only: read the monotonic navigation-token counter. */
1704
2155
  export function _navToken() { return currentNavigationToken; }
1705
2156
  /** Test-only: bump the navigation-token counter (simulates a fresh nav). */
@@ -1719,3 +2170,8 @@ export function _setCurrentPageUrl(u) { currentPageUrl = u; }
1719
2170
  export function _isNonHtmlPath(pathname) {
1720
2171
  return NON_HTML_EXTENSIONS.test(pathname);
1721
2172
  }
2173
+
2174
+ // Auto-enable on import (standard Turbo-Drive convention). Placed last so
2175
+ // every top-level binding the router touches (notably the prefetch state)
2176
+ // is initialised before enableClientRouter() runs.
2177
+ enableClientRouter();
package/src/serialize.js CHANGED
@@ -321,15 +321,26 @@ function decode(v, ctx) {
321
321
 
322
322
  function decodeTagged(v, tag, ctx) {
323
323
  const id = v[ID_KEY];
324
+ // Register the decoded value under its `_id` so a later `Ref` to it
325
+ // resolves. Container tags (Map/Set/Arr/Error/object) register BEFORE
326
+ // decoding children (so a cycle through the container resolves); leaf
327
+ // rich types (Date, typed arrays, Blob, ...) have no children and register
328
+ // through this helper at return. Without it, a SHARED Date or typed array
329
+ // (the same instance appearing twice) decodes the first copy but throws
330
+ // "Dangling reference" on the second.
331
+ const reg = (value) => {
332
+ if (typeof id === 'number') ctx.refs.set(id, value);
333
+ return value;
334
+ };
324
335
  switch (tag) {
325
336
  case 'undef': return undefined;
326
337
  case 'NaN': return NaN;
327
338
  case 'Inf': return Infinity;
328
339
  case '-Inf': return -Infinity;
329
340
  case '-0': return -0;
330
- case 'BigInt': return BigInt(v.v);
331
- case 'Sym': return Symbol.for(v.v);
332
- case 'Date': return v.v == null ? new Date(NaN) : new Date(v.v);
341
+ case 'BigInt': return reg(BigInt(v.v));
342
+ case 'Sym': return reg(Symbol.for(v.v));
343
+ case 'Date': return reg(v.v == null ? new Date(NaN) : new Date(v.v));
333
344
  case 'Error': {
334
345
  const e = new Error(v.v.message);
335
346
  e.name = v.v.name;
@@ -365,16 +376,17 @@ function decodeTagged(v, tag, ctx) {
365
376
  case 'Blob': {
366
377
  if (typeof Blob === 'undefined') throw new TypeError('Blob is not available in this environment.');
367
378
  const bytes = b64ToBytes(v.v);
368
- return new Blob([bytes], { type: v.t || '' });
379
+ return reg(new Blob([bytes], { type: v.t || '' }));
369
380
  }
370
381
  case 'File': {
371
382
  if (typeof File === 'undefined') throw new TypeError('File is not available in this environment.');
372
383
  const bytes = b64ToBytes(v.v);
373
- return new File([bytes], v.n, { type: v.t || '', lastModified: v.m });
384
+ return reg(new File([bytes], v.n, { type: v.t || '', lastModified: v.m }));
374
385
  }
375
386
  case 'FD': {
376
387
  if (typeof FormData === 'undefined') throw new TypeError('FormData is not available in this environment.');
377
388
  const fd = new FormData();
389
+ reg(fd);
378
390
  for (const [k, val] of v.v) {
379
391
  const decoded = decode(val, ctx);
380
392
  fd.append(k, decoded);
@@ -385,17 +397,17 @@ function decodeTagged(v, tag, ctx) {
385
397
  // Typed arrays + binary buffers
386
398
  if (tag === 'buf') {
387
399
  const bytes = b64ToBytes(v.v);
388
- return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
400
+ return reg(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
389
401
  }
390
402
  if (tag === 'dv') {
391
403
  const bytes = b64ToBytes(v.v);
392
- return new DataView(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength));
404
+ return reg(new DataView(bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength)));
393
405
  }
394
406
  const Ctor = TAG_TO_TYPED_ARRAY[tag];
395
407
  if (Ctor) {
396
408
  const bytes = b64ToBytes(v.v);
397
409
  const sliced = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
398
- return new Ctor(sliced);
410
+ return reg(new Ctor(sliced));
399
411
  }
400
412
  throw new TypeError(`Unknown serialization tag: ${tag}`);
401
413
  }