@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/dist/webjs-core-browser.js +3 -3
- package/dist/webjs-core-browser.js.map +2 -2
- package/dist/webjs-core.js +3 -3
- package/dist/webjs-core.js.map +2 -2
- package/package.json +1 -1
- package/src/router-client.js +472 -14
package/package.json
CHANGED
package/src/router-client.js
CHANGED
|
@@ -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
|
-
|
|
154
|
-
|
|
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,
|
|
173
|
-
document.removeEventListener('submit', onSubmit,
|
|
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
|
-
|
|
248
|
+
const key = u.pathname + u.search;
|
|
249
|
+
snapshotCache.delete(key);
|
|
250
|
+
prefetchCache.delete(key);
|
|
210
251
|
}
|
|
211
252
|
|
|
212
|
-
// Auto-enable on import (
|
|
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.
|
|
261
|
-
* `@submit`
|
|
262
|
-
*
|
|
263
|
-
*
|
|
264
|
-
*
|
|
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();
|