@moku-labs/web 2.4.15 → 2.4.16

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/index.mjs CHANGED
@@ -10511,89 +10511,6 @@ function notifyNavEnd(state, route = EMPTY_ROUTE) {
10511
10511
  }
10512
10512
  }
10513
10513
  //#endregion
10514
- //#region src/plugins/spa/progress.ts
10515
- /** Delay before the bar appears, so fast navigations show no indicator. */
10516
- const START_DELAY_MS = 150;
10517
- /** Interval between trickle increments while loading. */
10518
- const TRICKLE_MS = 300;
10519
- /** Linger before the completed bar is reset/hidden. */
10520
- const DONE_LINGER_MS = 200;
10521
- /** Ceiling the bar trickles to while still loading (never reaches 100% until done). */
10522
- const TRICKLE_CEIL = 90;
10523
- /** No-op progress bar used when disabled or in a headless context. */
10524
- const NOOP_BAR = {
10525
- start() {},
10526
- done() {}
10527
- };
10528
- /**
10529
- * Creates the in-house progress bar (150ms delay + trickle). A no-op shell when
10530
- * progress is disabled or no DOM is present. The progress element is created
10531
- * once (prepended to `<body>` as `<div data-progress>`) and reused across navs.
10532
- *
10533
- * @param enabled - Whether the progress bar is active.
10534
- * @returns A {@link ProgressBar} with `start`/`done`. Disabled/headless → no-ops.
10535
- * @example
10536
- * const bar = createProgressBar(true);
10537
- * bar.start();
10538
- * bar.done();
10539
- */
10540
- function createProgressBar(enabled) {
10541
- if (!enabled || typeof document === "undefined") return NOOP_BAR;
10542
- const element = document.createElement("div");
10543
- element.dataset.progress = "";
10544
- document.body.prepend(element);
10545
- let delayTimer;
10546
- let trickleTimer;
10547
- let width = 0;
10548
- /**
10549
- * Step the trickle upward toward the ceiling and reschedule.
10550
- *
10551
- * @example
10552
- * trickle();
10553
- */
10554
- const trickle = () => {
10555
- if (width >= TRICKLE_CEIL) return;
10556
- width = Math.min(TRICKLE_CEIL, width + 5 + Math.random() * 10);
10557
- element.style.width = `${String(width)}%`;
10558
- trickleTimer = setTimeout(trickle, TRICKLE_MS);
10559
- };
10560
- /**
10561
- * Show the bar after the start delay and begin trickling.
10562
- *
10563
- * @example
10564
- * start();
10565
- */
10566
- const start = () => {
10567
- delayTimer = setTimeout(() => {
10568
- width = 15;
10569
- element.style.width = "15%";
10570
- element.dataset.active = "";
10571
- trickle();
10572
- }, START_DELAY_MS);
10573
- };
10574
- /**
10575
- * Complete the bar to 100%, then reset/hide it after a short linger.
10576
- *
10577
- * @example
10578
- * done();
10579
- */
10580
- const done = () => {
10581
- clearTimeout(delayTimer);
10582
- clearTimeout(trickleTimer);
10583
- element.style.width = "100%";
10584
- element.dataset.active = "";
10585
- setTimeout(() => {
10586
- delete element.dataset.active;
10587
- element.style.width = "0%";
10588
- width = 0;
10589
- }, DONE_LINGER_MS);
10590
- };
10591
- return {
10592
- start,
10593
- done
10594
- };
10595
- }
10596
- //#endregion
10597
10514
  //#region src/plugins/spa/router.ts
10598
10515
  /**
10599
10516
  * Read the Navigation API global, or `undefined` when unsupported.
@@ -10621,9 +10538,10 @@ function isInternalLink(url) {
10621
10538
  /**
10622
10539
  * The navigable path of a URL or Location: pathname plus query string. The query
10623
10540
  * is part of page identity (the kernel's `currentUrl` is pathname + search), so
10624
- * same-page checks, history entries, fetches, and scroll keys must all carry it —
10625
- * comparing pathnames alone would treat `/search?q=a` → `/search?q=b` as same-page
10626
- * and the History fallback would drop the query from the address bar.
10541
+ * same-page checks, history entries, fetches, scroll keys and the preload cache key
10542
+ * must all carry it — comparing pathnames alone would treat `/search?q=a` →
10543
+ * `/search?q=b` as same-page and the History fallback would drop the query from the
10544
+ * address bar. The hash is not part of it.
10627
10545
  *
10628
10546
  * @param target - The URL or Location to read.
10629
10547
  * @param target.pathname - The path component.
@@ -10661,26 +10579,42 @@ function restoreScrollPosition(path) {
10661
10579
  } catch {}
10662
10580
  }
10663
10581
  /**
10664
- * Fetch a page and hand its HTML to the handlers; on any error fall back to a
10582
+ * Fetch a page's HTML. The one fetch-and-check shared by navigation and preload
10583
+ * (`prefetch.ts`): a response that is not ok is an error, never HTML to swap in.
10584
+ *
10585
+ * @param pathname - The page path (pathname + search).
10586
+ * @param signal - Aborts the request.
10587
+ * @returns The page HTML.
10588
+ * @throws {Error} When the response is not ok (`HTTP <status>`), or the fetch fails or aborts.
10589
+ * @example
10590
+ * await fetchPage("/about"); // "<!doctype html>…" — rejects with "HTTP 404" on a missing page
10591
+ */
10592
+ async function fetchPage(pathname, signal) {
10593
+ const response = await (signal ? fetch(pathname, { signal }) : fetch(pathname));
10594
+ if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
10595
+ return response.text();
10596
+ }
10597
+ /**
10598
+ * Load a page and hand its HTML to the handlers; on any error fall back to a
10665
10599
  * full browser navigation (`location.href = pathname`).
10666
10600
  *
10667
- * When `signal` aborts (this navigation was superseded by a newer one) the
10668
- * fetch is cancelled and NOTHING is applied: no swap (onEnd) and no fallback
10669
- * reload — the live navigation owns the document from that point on.
10601
+ * When `signal` aborts (this navigation was superseded by a newer one) NOTHING is
10602
+ * applied: no swap (onEnd) and no fallback reload — the live navigation owns the
10603
+ * document from that point on.
10670
10604
  *
10671
10605
  * @param pathname - The destination pathname.
10672
10606
  * @param handlers - The navigation lifecycle callbacks.
10673
10607
  * @param signal - Aborts when this navigation is superseded (`navEvent.signal`).
10608
+ * @param load - Where the HTML comes from: {@link fetchPage} by default; the kernel
10609
+ * passes a loader that serves a preloaded page first (`loadWithPrefetch`).
10674
10610
  * @returns A promise that resolves once the swap (or fallback) is dispatched.
10675
10611
  * @example
10676
10612
  * await performNavigation("/about", handlers, navEvent.signal);
10677
10613
  */
10678
- async function performNavigation(pathname, handlers, signal) {
10614
+ async function performNavigation(pathname, handlers, signal, load = fetchPage) {
10679
10615
  handlers.onStart(pathname);
10680
10616
  try {
10681
- const response = await (signal ? fetch(pathname, { signal }) : fetch(pathname));
10682
- if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
10683
- const html = await response.text();
10617
+ const html = await load(pathname, signal);
10684
10618
  if (signal?.aborted) return;
10685
10619
  handlers.onEnd(html, pathname);
10686
10620
  } catch {
@@ -10788,18 +10722,16 @@ function swapRegion(doc, swapSelector, transition, onSwapped, beforeCapture) {
10788
10722
  return true;
10789
10723
  }
10790
10724
  /**
10791
- * Resolve a navigable internal URL from a click event, or `undefined` when the
10792
- * click should not be intercepted (modifier keys, non-anchor, external, new-tab).
10725
+ * Resolve the internal URL a link leads to, or `undefined` when the SPA leaves it
10726
+ * to the browser (no anchor, `target="_blank"`, another origin, a static asset). The
10727
+ * one link rule shared by the click interceptor and the preloader (`prefetch.ts`).
10793
10728
  *
10794
- * @param event - The click event to inspect.
10795
- * @returns The internal URL to navigate to, or `undefined`.
10729
+ * @param anchor - The link element, or nothing.
10730
+ * @returns The internal URL, or `undefined`.
10796
10731
  * @example
10797
- * const url = resolveClickTarget(event);
10732
+ * resolveLinkTarget(document.querySelector("a[href='/about']"))?.pathname; // "/about"
10798
10733
  */
10799
- function resolveClickTarget(event) {
10800
- if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return void 0;
10801
- if (event.defaultPrevented) return void 0;
10802
- const anchor = event.target?.closest("a");
10734
+ function resolveLinkTarget(anchor) {
10803
10735
  if (!anchor || anchor.target === "_blank") return void 0;
10804
10736
  let url;
10805
10737
  try {
@@ -10810,6 +10742,20 @@ function resolveClickTarget(event) {
10810
10742
  return isInternalLink(url) ? url : void 0;
10811
10743
  }
10812
10744
  /**
10745
+ * Resolve a navigable internal URL from a click event, or `undefined` when the
10746
+ * click should not be intercepted (modifier keys, non-anchor, external, new-tab).
10747
+ *
10748
+ * @param event - The click event to inspect.
10749
+ * @returns The internal URL to navigate to, or `undefined`.
10750
+ * @example
10751
+ * const url = resolveClickTarget(event);
10752
+ */
10753
+ function resolveClickTarget(event) {
10754
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return void 0;
10755
+ if (event.defaultPrevented) return void 0;
10756
+ return resolveLinkTarget(event.target?.closest("a"));
10757
+ }
10758
+ /**
10813
10759
  * Attach the History-API click/popstate interception path (used when the
10814
10760
  * Navigation API is unavailable).
10815
10761
  *
@@ -10942,11 +10888,412 @@ function attachRouter(handlers, navigate, isOwnPush) {
10942
10888
  return navigation ? attachNavigationApi(navigation, handlers, navigate, isOwnPush) : attachHistoryFallback(handlers, navigate);
10943
10889
  }
10944
10890
  //#endregion
10891
+ //#region src/plugins/spa/prefetch.ts
10892
+ /**
10893
+ * @file spa plugin — opt-in in-memory HTML preload cache (`spa.prefetch`).
10894
+ * @see README.md
10895
+ */
10896
+ const HOVER_DELAY_MS = 65;
10897
+ const TTL_MS = 3e4;
10898
+ const MAX_ENTRIES = 20;
10899
+ const VISIBLE_CONCURRENCY = 2;
10900
+ const IDLE_FALLBACK_MS = 200;
10901
+ const SLOW_CONNECTION = /(^|-)2g$/;
10902
+ const PRESS = {
10903
+ capture: true,
10904
+ passive: true
10905
+ };
10906
+ const HOVER = { passive: true };
10907
+ /**
10908
+ * Read `navigator.connection` (mirrors `getNavigation()` in `router.ts`).
10909
+ *
10910
+ * @returns The connection info, or `undefined` where the browser has none.
10911
+ * @example
10912
+ * getConnection()?.saveData; // true with data saving on
10913
+ */
10914
+ function getConnection() {
10915
+ return navigator.connection;
10916
+ }
10917
+ /**
10918
+ * Whether no preload may start now: data saving on, or a 2G connection.
10919
+ *
10920
+ * @returns True when preloads must wait.
10921
+ * @example
10922
+ * isConstrained(); // true on effectiveType "slow-2g"
10923
+ */
10924
+ function isConstrained() {
10925
+ const connection = getConnection();
10926
+ return connection?.saveData === true || SLOW_CONNECTION.test(connection?.effectiveType ?? "");
10927
+ }
10928
+ /**
10929
+ * The link an event happened on.
10930
+ *
10931
+ * @param event - A press or mouse event.
10932
+ * @returns The closest `<a>`, or nothing.
10933
+ * @example
10934
+ * anchorOf(event); // <a href="/about"> for a tap on the <span> inside it
10935
+ */
10936
+ function anchorOf(event) {
10937
+ return event.target instanceof Element ? event.target.closest("a") : void 0;
10938
+ }
10939
+ /**
10940
+ * Run `task` in idle time, or after 200 ms where `requestIdleCallback` is missing.
10941
+ *
10942
+ * @param task - The work to run.
10943
+ * @returns Cancels the task.
10944
+ * @example
10945
+ * const cancel = whenIdle(drain); // cancel() before idle time: drain never runs
10946
+ */
10947
+ function whenIdle(task) {
10948
+ if (typeof requestIdleCallback === "function") {
10949
+ const handle = requestIdleCallback(task);
10950
+ return () => cancelIdleCallback(handle);
10951
+ }
10952
+ const handle = setTimeout(task, IDLE_FALLBACK_MS);
10953
+ return () => clearTimeout(handle);
10954
+ }
10955
+ /**
10956
+ * Build the preloader: press and hover listeners on `document` at once, and for
10957
+ * `"visible"` one IntersectionObserver that {@link Prefetcher.observe} feeds.
10958
+ *
10959
+ * @param options - The mode, the HTML-path check and the clock.
10960
+ * @returns The live preloader.
10961
+ * @example
10962
+ * createPrefetcher({ mode: "intent", usesHtml: () => true }).take("/about"); // undefined
10963
+ */
10964
+ function createPrefetcher(options) {
10965
+ const now = options.now ?? Date.now;
10966
+ const cache = /* @__PURE__ */ new Map();
10967
+ const hoverTimers = /* @__PURE__ */ new Map();
10968
+ const queue = [];
10969
+ let visibleInFlight = 0;
10970
+ let cancelDrain;
10971
+ /**
10972
+ * Whether an entry is older than the TTL.
10973
+ *
10974
+ * @param entry - A cache entry.
10975
+ * @returns True when the entry must not be used.
10976
+ * @example
10977
+ * isExpired(entry); // true 30_001 ms after it started
10978
+ */
10979
+ const isExpired = (entry) => now() - entry.startedAt > TTL_MS;
10980
+ /**
10981
+ * Abort an entry's request and remove it.
10982
+ *
10983
+ * @param key - The entry's path.
10984
+ * @param entry - The entry.
10985
+ * @example
10986
+ * drop("/a/", entry);
10987
+ */
10988
+ const drop = (key, entry) => {
10989
+ entry.controller.abort();
10990
+ cache.delete(key);
10991
+ };
10992
+ /**
10993
+ * The path a link would preload, or `undefined` when it is not eligible.
10994
+ *
10995
+ * @param anchor - The link, or nothing.
10996
+ * @returns The path (pathname + search), or `undefined`.
10997
+ * @example
10998
+ * pathOf(document.querySelector("a[href='/about?x=1#top']")); // "/about?x=1"
10999
+ */
11000
+ const pathOf = (anchor) => {
11001
+ const url = resolveLinkTarget(anchor);
11002
+ if (!url || anchor?.hasAttribute("download")) return void 0;
11003
+ const path = pathWithSearch(url);
11004
+ return path !== pathWithSearch(location) && options.usesHtml(path) ? path : void 0;
11005
+ };
11006
+ /**
11007
+ * Start one preload, unless the connection forbids it or the path is already cached.
11008
+ *
11009
+ * @param path - The page path.
11010
+ * @returns The new entry, or `undefined` when nothing started.
11011
+ * @example
11012
+ * start("/about"); // { promise, startedAt, controller }
11013
+ */
11014
+ const start = (path) => {
11015
+ if (isConstrained()) return void 0;
11016
+ for (const [key, entry] of cache) if (isExpired(entry)) drop(key, entry);
11017
+ if (cache.has(path)) return void 0;
11018
+ for (const [key, entry] of cache) {
11019
+ if (cache.size < MAX_ENTRIES) break;
11020
+ drop(key, entry);
11021
+ }
11022
+ const controller = new AbortController();
11023
+ const promise = fetchPage(path, controller.signal);
11024
+ const entry = {
11025
+ promise,
11026
+ startedAt: now(),
11027
+ controller
11028
+ };
11029
+ cache.set(path, entry);
11030
+ promise.catch(() => {
11031
+ if (cache.get(path) === entry) cache.delete(path);
11032
+ });
11033
+ return entry;
11034
+ };
11035
+ /**
11036
+ * Preload a link when it is eligible.
11037
+ *
11038
+ * @param anchor - The link, or nothing.
11039
+ * @example
11040
+ * preloadLink(document.querySelector("a"));
11041
+ */
11042
+ const preloadLink = (anchor) => {
11043
+ const path = pathOf(anchor);
11044
+ if (path !== void 0) start(path);
11045
+ };
11046
+ /**
11047
+ * Start queued `"visible"` preloads while fewer than 2 are in flight.
11048
+ *
11049
+ * @example
11050
+ * cancelDrain = whenIdle(drain);
11051
+ */
11052
+ const drain = () => {
11053
+ cancelDrain = void 0;
11054
+ while (visibleInFlight < VISIBLE_CONCURRENCY) {
11055
+ const path = queue.shift();
11056
+ if (path === void 0) return;
11057
+ const entry = start(path);
11058
+ if (!entry) continue;
11059
+ visibleInFlight += 1;
11060
+ entry.promise.catch(() => "").then(() => {
11061
+ visibleInFlight -= 1;
11062
+ scheduleDrain();
11063
+ });
11064
+ }
11065
+ };
11066
+ /**
11067
+ * Drain the queue in idle time, once.
11068
+ *
11069
+ * @example
11070
+ * scheduleDrain();
11071
+ */
11072
+ const scheduleDrain = () => {
11073
+ if (cancelDrain === void 0 && queue.length > 0) cancelDrain = whenIdle(drain);
11074
+ };
11075
+ /**
11076
+ * `pointerdown` / `touchstart`: preload at once.
11077
+ *
11078
+ * @param event - The press.
11079
+ * @example
11080
+ * document.addEventListener("pointerdown", onPress, PRESS);
11081
+ */
11082
+ const onPress = (event) => {
11083
+ preloadLink(anchorOf(event));
11084
+ };
11085
+ /**
11086
+ * `mouseover`: preload after 65 ms on the link.
11087
+ *
11088
+ * @param event - The mouse event.
11089
+ * @example
11090
+ * document.addEventListener("mouseover", onOver, HOVER);
11091
+ */
11092
+ const onOver = (event) => {
11093
+ const anchor = anchorOf(event);
11094
+ if (!anchor || hoverTimers.has(anchor)) return;
11095
+ const timer = setTimeout(() => {
11096
+ hoverTimers.delete(anchor);
11097
+ preloadLink(anchor);
11098
+ }, HOVER_DELAY_MS);
11099
+ hoverTimers.set(anchor, timer);
11100
+ };
11101
+ /**
11102
+ * `mouseout`: cancel the hover timer, unless the mouse only moved inside the same link.
11103
+ *
11104
+ * @param event - The mouse event.
11105
+ * @example
11106
+ * document.addEventListener("mouseout", onOut, HOVER);
11107
+ */
11108
+ const onOut = (event) => {
11109
+ const anchor = anchorOf(event);
11110
+ const into = event.relatedTarget;
11111
+ if (!anchor || into instanceof Node && anchor.contains(into)) return;
11112
+ clearTimeout(hoverTimers.get(anchor));
11113
+ hoverTimers.delete(anchor);
11114
+ };
11115
+ const observer = options.mode === "visible" && typeof IntersectionObserver === "function" ? new IntersectionObserver((entries, self) => {
11116
+ for (const entry of entries) {
11117
+ if (!entry.isIntersecting) continue;
11118
+ self.unobserve(entry.target);
11119
+ const path = pathOf(entry.target.closest("a"));
11120
+ if (path !== void 0) queue.push(path);
11121
+ }
11122
+ scheduleDrain();
11123
+ }) : void 0;
11124
+ document.addEventListener("pointerdown", onPress, PRESS);
11125
+ document.addEventListener("touchstart", onPress, PRESS);
11126
+ document.addEventListener("mouseover", onOver, HOVER);
11127
+ document.addEventListener("mouseout", onOut, HOVER);
11128
+ return {
11129
+ /**
11130
+ * Take the preload for `path` and remove it (see {@link Prefetcher.take}).
11131
+ *
11132
+ * @param path - The page path.
11133
+ * @returns The page HTML promise, or `undefined`.
11134
+ * @example
11135
+ * prefetcher.take("/about");
11136
+ */
11137
+ take(path) {
11138
+ const entry = cache.get(path);
11139
+ if (!entry) return void 0;
11140
+ if (isExpired(entry)) {
11141
+ drop(path, entry);
11142
+ return;
11143
+ }
11144
+ cache.delete(path);
11145
+ return entry.promise;
11146
+ },
11147
+ /**
11148
+ * Watch every eligible link in the document (see {@link Prefetcher.observe}).
11149
+ *
11150
+ * @example
11151
+ * prefetcher.observe();
11152
+ */
11153
+ observe() {
11154
+ if (!observer) return;
11155
+ observer.disconnect();
11156
+ queue.length = 0;
11157
+ for (const anchor of document.querySelectorAll("a[href]")) if (pathOf(anchor) !== void 0) observer.observe(anchor);
11158
+ },
11159
+ /**
11160
+ * Release everything (see {@link Prefetcher.dispose}).
11161
+ *
11162
+ * @example
11163
+ * prefetcher.dispose();
11164
+ */
11165
+ dispose() {
11166
+ document.removeEventListener("pointerdown", onPress, PRESS);
11167
+ document.removeEventListener("touchstart", onPress, PRESS);
11168
+ document.removeEventListener("mouseover", onOver, HOVER);
11169
+ document.removeEventListener("mouseout", onOut, HOVER);
11170
+ observer?.disconnect();
11171
+ for (const timer of hoverTimers.values()) clearTimeout(timer);
11172
+ hoverTimers.clear();
11173
+ cancelDrain?.();
11174
+ cancelDrain = void 0;
11175
+ queue.length = 0;
11176
+ for (const [key, entry] of cache) drop(key, entry);
11177
+ }
11178
+ };
11179
+ }
11180
+ /**
11181
+ * The HTML loader the kernel hands to `performNavigation`: a preloaded page when there is
11182
+ * one, else a normal fetch. A failed preload falls through to one real fetch, so it never
11183
+ * leads to a full reload; only a failed real fetch does, as without preloading.
11184
+ *
11185
+ * @param prefetcher - The live preloader, or `null` (mode `false`, or stopped).
11186
+ * @param path - The page path (pathname + search).
11187
+ * @param signal - The navigation's abort signal; never passed to the preload.
11188
+ * @returns The page HTML.
11189
+ * @throws {Error} When the real fetch fails, or the navigation was aborted after its preload failed.
11190
+ * @example
11191
+ * await loadWithPrefetch(state.prefetch, "/about", signal); // the preloaded HTML, no new request
11192
+ */
11193
+ async function loadWithPrefetch(prefetcher, path, signal) {
11194
+ const hit = prefetcher?.take(path);
11195
+ if (hit) try {
11196
+ return await hit;
11197
+ } catch {
11198
+ signal?.throwIfAborted();
11199
+ }
11200
+ return fetchPage(path, signal);
11201
+ }
11202
+ //#endregion
11203
+ //#region src/plugins/spa/progress.ts
11204
+ /** Delay before the bar appears, so fast navigations show no indicator. */
11205
+ const START_DELAY_MS = 150;
11206
+ /** Interval between trickle increments while loading. */
11207
+ const TRICKLE_MS = 300;
11208
+ /** Linger before the completed bar is reset/hidden. */
11209
+ const DONE_LINGER_MS = 200;
11210
+ /** Ceiling the bar trickles to while still loading (never reaches 100% until done). */
11211
+ const TRICKLE_CEIL = 90;
11212
+ /** No-op progress bar used when disabled or in a headless context. */
11213
+ const NOOP_BAR = {
11214
+ start() {},
11215
+ done() {}
11216
+ };
11217
+ /**
11218
+ * Creates the in-house progress bar (150ms delay + trickle). A no-op shell when
11219
+ * progress is disabled or no DOM is present. The progress element is created
11220
+ * once (prepended to `<body>` as `<div data-progress>`) and reused across navs.
11221
+ *
11222
+ * @param enabled - Whether the progress bar is active.
11223
+ * @returns A {@link ProgressBar} with `start`/`done`. Disabled/headless → no-ops.
11224
+ * @example
11225
+ * const bar = createProgressBar(true);
11226
+ * bar.start();
11227
+ * bar.done();
11228
+ */
11229
+ function createProgressBar(enabled) {
11230
+ if (!enabled || typeof document === "undefined") return NOOP_BAR;
11231
+ const element = document.createElement("div");
11232
+ element.dataset.progress = "";
11233
+ document.body.prepend(element);
11234
+ let delayTimer;
11235
+ let trickleTimer;
11236
+ let width = 0;
11237
+ /**
11238
+ * Step the trickle upward toward the ceiling and reschedule.
11239
+ *
11240
+ * @example
11241
+ * trickle();
11242
+ */
11243
+ const trickle = () => {
11244
+ if (width >= TRICKLE_CEIL) return;
11245
+ width = Math.min(TRICKLE_CEIL, width + 5 + Math.random() * 10);
11246
+ element.style.width = `${String(width)}%`;
11247
+ trickleTimer = setTimeout(trickle, TRICKLE_MS);
11248
+ };
11249
+ /**
11250
+ * Show the bar after the start delay and begin trickling.
11251
+ *
11252
+ * @example
11253
+ * start();
11254
+ */
11255
+ const start = () => {
11256
+ delayTimer = setTimeout(() => {
11257
+ width = 15;
11258
+ element.style.width = "15%";
11259
+ element.dataset.active = "";
11260
+ trickle();
11261
+ }, START_DELAY_MS);
11262
+ };
11263
+ /**
11264
+ * Complete the bar to 100%, then reset/hide it after a short linger.
11265
+ *
11266
+ * @example
11267
+ * done();
11268
+ */
11269
+ const done = () => {
11270
+ clearTimeout(delayTimer);
11271
+ clearTimeout(trickleTimer);
11272
+ element.style.width = "100%";
11273
+ element.dataset.active = "";
11274
+ setTimeout(() => {
11275
+ delete element.dataset.active;
11276
+ element.style.width = "0%";
11277
+ width = 0;
11278
+ }, DONE_LINGER_MS);
11279
+ };
11280
+ return {
11281
+ start,
11282
+ done
11283
+ };
11284
+ }
11285
+ //#endregion
10945
11286
  //#region src/plugins/spa/state.ts
10946
11287
  /** Error prefix for spa config-validation failures (spec/11 Part-3). */
10947
11288
  const ERROR_PREFIX$1 = "[web]";
10948
11289
  /** Last-resort `swapSelector` when neither config nor defaults supply one. */
10949
11290
  const FALLBACK_SWAP_SELECTOR = "main > section";
11291
+ /** Every value `spa.prefetch` accepts. */
11292
+ const PREFETCH_MODES = new Set([
11293
+ false,
11294
+ "intent",
11295
+ "visible"
11296
+ ]);
10950
11297
  /**
10951
11298
  * Resolve the `viewTransitions` config value into the default {@link TransitionMode}:
10952
11299
  * `true` → `"crossfade"`, `false` → `"none"`, a named mode → itself.
@@ -10961,13 +11308,28 @@ function resolveDefaultTransition(raw) {
10961
11308
  if (raw === false) return "none";
10962
11309
  return raw;
10963
11310
  }
11311
+ /**
11312
+ * Validate `spa.prefetch` (spec/11 Part-3 error). An untyped config can carry any value,
11313
+ * so a typo like `"hover"` fails at init instead of silently disabling the preload.
11314
+ *
11315
+ * @param mode - The raw `prefetch` config value (absent ⇒ `false`).
11316
+ * @returns The validated mode.
11317
+ * @throws {Error} When the value is not `false`, `"intent"` or `"visible"`.
11318
+ * @example
11319
+ * resolvePrefetchMode(undefined); // false
11320
+ */
11321
+ function resolvePrefetchMode(mode = false) {
11322
+ if (!PREFETCH_MODES.has(mode)) throw new Error(`${ERROR_PREFIX$1} spa.prefetch must be false, "intent" or "visible". Got ${JSON.stringify(mode)}.\n Set spa.prefetch to one of the allowed values, or leave it out.`);
11323
+ return mode;
11324
+ }
10964
11325
  /** Default SPA config (declared as a value — no inline assertion). */
10965
11326
  const defaultSpaConfig = {
10966
11327
  swapSelector: "main > section",
10967
11328
  viewTransitions: false,
10968
11329
  scrollRestoration: "top",
10969
11330
  progressBar: true,
10970
- islands: []
11331
+ islands: [],
11332
+ prefetch: false
10971
11333
  };
10972
11334
  /**
10973
11335
  * Whether a selector is syntactically valid (parseable by the DOM). Falls back
@@ -10994,7 +11356,8 @@ function isValidSelector(selector) {
10994
11356
  *
10995
11357
  * @param config - The raw spa config to validate.
10996
11358
  * @returns The fully-resolved config with defaults applied.
10997
- * @throws {Error} When `swapSelector` is empty or not a valid CSS selector.
11359
+ * @throws {Error} When `swapSelector` is empty or not a valid CSS selector, or `prefetch`
11360
+ * is not one of its three values.
10998
11361
  * @example
10999
11362
  * const resolved = resolveSpaConfig({ swapSelector: "main > section" });
11000
11363
  */
@@ -11009,7 +11372,8 @@ function resolveSpaConfig(config) {
11009
11372
  defaultTransition,
11010
11373
  scrollRestoration: config.scrollRestoration ?? "top",
11011
11374
  progressBar: config.progressBar ?? true,
11012
- islands: config.islands ?? []
11375
+ islands: config.islands ?? [],
11376
+ prefetch: resolvePrefetchMode(config.prefetch)
11013
11377
  };
11014
11378
  }
11015
11379
  /**
@@ -11033,7 +11397,8 @@ function createState(_ctx) {
11033
11397
  destroyRouter: null,
11034
11398
  started: false,
11035
11399
  navigate: null,
11036
- kernel: null
11400
+ kernel: null,
11401
+ prefetch: null
11037
11402
  };
11038
11403
  }
11039
11404
  //#endregion
@@ -11071,6 +11436,21 @@ function currentLocationUrl() {
11071
11436
  return location.pathname + location.search;
11072
11437
  }
11073
11438
  /**
11439
+ * Whether a navigation to `path` renders from fetched HTML, so a preload of it is used.
11440
+ *
11441
+ * @param deps - The router and the optional data reader.
11442
+ * @param path - The page path (pathname + search).
11443
+ * @returns False when the client DATA path renders the page.
11444
+ * @example
11445
+ * usesHtml({ router, head }, "/en/about/"); // true: no data reader, every page is HTML
11446
+ */
11447
+ function usesHtml(deps, path) {
11448
+ if (deps.router.mode() === "ssg") return true;
11449
+ const route = deps.router.match(path.split("?")[0] ?? path)?.route;
11450
+ if (!route?._handlers.render) return true;
11451
+ return !isClientOnlyRoute(deps.router.mode(), route) && !deps.dataAt;
11452
+ }
11453
+ /**
11074
11454
  * Apply the matched route's `head` config to the live document (minimal client
11075
11455
  * head-sync for the DATA path: title only — the full meta sync runs on the
11076
11456
  * HTML-over-fetch path from the fetched `<head>`). The title is resolved through
@@ -11230,6 +11610,7 @@ function createSpaKernel(state, config, emit, deps) {
11230
11610
  const routeSlice = islandRouteContext(pathname);
11231
11611
  scanAndMount(state, emit, resolved.swapSelector, routeSlice);
11232
11612
  notifyNavEnd(state, routeSlice);
11613
+ state.prefetch?.observe();
11233
11614
  }, applyPendingScroll)) {
11234
11615
  handleError();
11235
11616
  location.href = pathname;
@@ -11344,6 +11725,7 @@ function createSpaKernel(state, config, emit, deps) {
11344
11725
  renderVNode(vnode, region);
11345
11726
  scanAndMount(state, emit, resolved.swapSelector, routeSlice);
11346
11727
  notifyNavEnd(state, routeSlice);
11728
+ state.prefetch?.observe();
11347
11729
  };
11348
11730
  runSwap(renderAndMount, pendingTransition, applyPendingScroll);
11349
11731
  state.currentUrl = pathname;
@@ -11392,12 +11774,14 @@ function createSpaKernel(state, config, emit, deps) {
11392
11774
  const resolvedRender = await resolveDataRender(pathname);
11393
11775
  if (resolvedRender === false) {
11394
11776
  scanAndMount(state, emit, resolved.swapSelector, routeSlice);
11777
+ state.prefetch?.observe();
11395
11778
  return;
11396
11779
  }
11397
11780
  const { vnode, region } = resolvedRender;
11398
11781
  const { renderVNode } = await import("./render-CkdqOmsm.mjs");
11399
11782
  renderVNode(vnode, region);
11400
11783
  scanAndMount(state, emit, resolved.swapSelector, routeSlice);
11784
+ state.prefetch?.observe();
11401
11785
  };
11402
11786
  /**
11403
11787
  * Unified navigation: try the client DATA path first (only when the `data`
@@ -11421,7 +11805,7 @@ function createSpaKernel(state, config, emit, deps) {
11421
11805
  handleStart(pathname);
11422
11806
  if (deps.router.mode() !== "ssg" && await tryDataRender(pathname, signal)) return;
11423
11807
  if (signal?.aborted) return;
11424
- await performNavigation(pathname, handlers, signal);
11808
+ await performNavigation(pathname, handlers, signal, (path, loadSignal) => loadWithPrefetch(state.prefetch, path, loadSignal));
11425
11809
  };
11426
11810
  /**
11427
11811
  * Programmatic navigation entry (`app.spa.navigate` / `ctx.navigate`). A link click or the
@@ -11489,10 +11873,17 @@ function createSpaKernel(state, config, emit, deps) {
11489
11873
  supersedeProgrammatic();
11490
11874
  return navigate(pathname, scrollToTop, signal, scrollOverride);
11491
11875
  }, () => pushingOwnUrl);
11876
+ if (resolved.prefetch !== false) state.prefetch = createPrefetcher({
11877
+ mode: resolved.prefetch,
11878
+ usesHtml: (path) => usesHtml(deps, path)
11879
+ });
11492
11880
  const matchPath = state.currentUrl.split("?")[0] ?? state.currentUrl;
11493
11881
  const hit = deps.router.match(matchPath);
11494
11882
  if (hit?.route._handlers.render && isClientOnlyRoute(deps.router.mode(), hit.route)) bootRender(state.currentUrl);
11495
- else scanAndMount(state, emit, resolved.swapSelector, islandRouteContext(state.currentUrl));
11883
+ else {
11884
+ scanAndMount(state, emit, resolved.swapSelector, islandRouteContext(state.currentUrl));
11885
+ state.prefetch?.observe();
11886
+ }
11496
11887
  state.started = true;
11497
11888
  },
11498
11889
  /**
@@ -11532,6 +11923,8 @@ function createSpaKernel(state, config, emit, deps) {
11532
11923
  if (typeof document === "undefined") return;
11533
11924
  state.destroyRouter?.();
11534
11925
  state.destroyRouter = null;
11926
+ state.prefetch?.dispose();
11927
+ state.prefetch = null;
11535
11928
  location.assign(url);
11536
11929
  },
11537
11930
  /**
@@ -11552,6 +11945,8 @@ function createSpaKernel(state, config, emit, deps) {
11552
11945
  dispose() {
11553
11946
  state.destroyRouter?.();
11554
11947
  state.destroyRouter = null;
11948
+ state.prefetch?.dispose();
11949
+ state.prefetch = null;
11555
11950
  unmountAll(state, emit);
11556
11951
  progress = void 0;
11557
11952
  state.started = false;