@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/dist/webjs-core-browser.js +3 -3
- package/dist/webjs-core-browser.js.map +2 -2
- package/dist/webjs-core-testing.js +2 -2
- package/dist/webjs-core-testing.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/render-server.js +44 -1
- package/src/router-client.js +462 -6
- package/src/serialize.js +20 -8
package/package.json
CHANGED
package/src/render-server.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/src/router-client.js
CHANGED
|
@@ -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
|
-
|
|
248
|
+
const key = u.pathname + u.search;
|
|
249
|
+
snapshotCache.delete(key);
|
|
250
|
+
prefetchCache.delete(key);
|
|
224
251
|
}
|
|
225
252
|
|
|
226
|
-
// Auto-enable on import (
|
|
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
|
}
|