@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.cjs CHANGED
@@ -10555,89 +10555,6 @@ function notifyNavEnd(state, route = EMPTY_ROUTE) {
10555
10555
  }
10556
10556
  }
10557
10557
  //#endregion
10558
- //#region src/plugins/spa/progress.ts
10559
- /** Delay before the bar appears, so fast navigations show no indicator. */
10560
- const START_DELAY_MS = 150;
10561
- /** Interval between trickle increments while loading. */
10562
- const TRICKLE_MS = 300;
10563
- /** Linger before the completed bar is reset/hidden. */
10564
- const DONE_LINGER_MS = 200;
10565
- /** Ceiling the bar trickles to while still loading (never reaches 100% until done). */
10566
- const TRICKLE_CEIL = 90;
10567
- /** No-op progress bar used when disabled or in a headless context. */
10568
- const NOOP_BAR = {
10569
- start() {},
10570
- done() {}
10571
- };
10572
- /**
10573
- * Creates the in-house progress bar (150ms delay + trickle). A no-op shell when
10574
- * progress is disabled or no DOM is present. The progress element is created
10575
- * once (prepended to `<body>` as `<div data-progress>`) and reused across navs.
10576
- *
10577
- * @param enabled - Whether the progress bar is active.
10578
- * @returns A {@link ProgressBar} with `start`/`done`. Disabled/headless → no-ops.
10579
- * @example
10580
- * const bar = createProgressBar(true);
10581
- * bar.start();
10582
- * bar.done();
10583
- */
10584
- function createProgressBar(enabled) {
10585
- if (!enabled || typeof document === "undefined") return NOOP_BAR;
10586
- const element = document.createElement("div");
10587
- element.dataset.progress = "";
10588
- document.body.prepend(element);
10589
- let delayTimer;
10590
- let trickleTimer;
10591
- let width = 0;
10592
- /**
10593
- * Step the trickle upward toward the ceiling and reschedule.
10594
- *
10595
- * @example
10596
- * trickle();
10597
- */
10598
- const trickle = () => {
10599
- if (width >= TRICKLE_CEIL) return;
10600
- width = Math.min(TRICKLE_CEIL, width + 5 + Math.random() * 10);
10601
- element.style.width = `${String(width)}%`;
10602
- trickleTimer = setTimeout(trickle, TRICKLE_MS);
10603
- };
10604
- /**
10605
- * Show the bar after the start delay and begin trickling.
10606
- *
10607
- * @example
10608
- * start();
10609
- */
10610
- const start = () => {
10611
- delayTimer = setTimeout(() => {
10612
- width = 15;
10613
- element.style.width = "15%";
10614
- element.dataset.active = "";
10615
- trickle();
10616
- }, START_DELAY_MS);
10617
- };
10618
- /**
10619
- * Complete the bar to 100%, then reset/hide it after a short linger.
10620
- *
10621
- * @example
10622
- * done();
10623
- */
10624
- const done = () => {
10625
- clearTimeout(delayTimer);
10626
- clearTimeout(trickleTimer);
10627
- element.style.width = "100%";
10628
- element.dataset.active = "";
10629
- setTimeout(() => {
10630
- delete element.dataset.active;
10631
- element.style.width = "0%";
10632
- width = 0;
10633
- }, DONE_LINGER_MS);
10634
- };
10635
- return {
10636
- start,
10637
- done
10638
- };
10639
- }
10640
- //#endregion
10641
10558
  //#region src/plugins/spa/router.ts
10642
10559
  /**
10643
10560
  * Read the Navigation API global, or `undefined` when unsupported.
@@ -10665,9 +10582,10 @@ function isInternalLink(url) {
10665
10582
  /**
10666
10583
  * The navigable path of a URL or Location: pathname plus query string. The query
10667
10584
  * is part of page identity (the kernel's `currentUrl` is pathname + search), so
10668
- * same-page checks, history entries, fetches, and scroll keys must all carry it —
10669
- * comparing pathnames alone would treat `/search?q=a` → `/search?q=b` as same-page
10670
- * and the History fallback would drop the query from the address bar.
10585
+ * same-page checks, history entries, fetches, scroll keys and the preload cache key
10586
+ * must all carry it — comparing pathnames alone would treat `/search?q=a` →
10587
+ * `/search?q=b` as same-page and the History fallback would drop the query from the
10588
+ * address bar. The hash is not part of it.
10671
10589
  *
10672
10590
  * @param target - The URL or Location to read.
10673
10591
  * @param target.pathname - The path component.
@@ -10705,26 +10623,42 @@ function restoreScrollPosition(path) {
10705
10623
  } catch {}
10706
10624
  }
10707
10625
  /**
10708
- * Fetch a page and hand its HTML to the handlers; on any error fall back to a
10626
+ * Fetch a page's HTML. The one fetch-and-check shared by navigation and preload
10627
+ * (`prefetch.ts`): a response that is not ok is an error, never HTML to swap in.
10628
+ *
10629
+ * @param pathname - The page path (pathname + search).
10630
+ * @param signal - Aborts the request.
10631
+ * @returns The page HTML.
10632
+ * @throws {Error} When the response is not ok (`HTTP <status>`), or the fetch fails or aborts.
10633
+ * @example
10634
+ * await fetchPage("/about"); // "<!doctype html>…" — rejects with "HTTP 404" on a missing page
10635
+ */
10636
+ async function fetchPage(pathname, signal) {
10637
+ const response = await (signal ? fetch(pathname, { signal }) : fetch(pathname));
10638
+ if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
10639
+ return response.text();
10640
+ }
10641
+ /**
10642
+ * Load a page and hand its HTML to the handlers; on any error fall back to a
10709
10643
  * full browser navigation (`location.href = pathname`).
10710
10644
  *
10711
- * When `signal` aborts (this navigation was superseded by a newer one) the
10712
- * fetch is cancelled and NOTHING is applied: no swap (onEnd) and no fallback
10713
- * reload — the live navigation owns the document from that point on.
10645
+ * When `signal` aborts (this navigation was superseded by a newer one) NOTHING is
10646
+ * applied: no swap (onEnd) and no fallback reload — the live navigation owns the
10647
+ * document from that point on.
10714
10648
  *
10715
10649
  * @param pathname - The destination pathname.
10716
10650
  * @param handlers - The navigation lifecycle callbacks.
10717
10651
  * @param signal - Aborts when this navigation is superseded (`navEvent.signal`).
10652
+ * @param load - Where the HTML comes from: {@link fetchPage} by default; the kernel
10653
+ * passes a loader that serves a preloaded page first (`loadWithPrefetch`).
10718
10654
  * @returns A promise that resolves once the swap (or fallback) is dispatched.
10719
10655
  * @example
10720
10656
  * await performNavigation("/about", handlers, navEvent.signal);
10721
10657
  */
10722
- async function performNavigation(pathname, handlers, signal) {
10658
+ async function performNavigation(pathname, handlers, signal, load = fetchPage) {
10723
10659
  handlers.onStart(pathname);
10724
10660
  try {
10725
- const response = await (signal ? fetch(pathname, { signal }) : fetch(pathname));
10726
- if (!response.ok) throw new Error(`HTTP ${String(response.status)}`);
10727
- const html = await response.text();
10661
+ const html = await load(pathname, signal);
10728
10662
  if (signal?.aborted) return;
10729
10663
  handlers.onEnd(html, pathname);
10730
10664
  } catch {
@@ -10832,18 +10766,16 @@ function swapRegion(doc, swapSelector, transition, onSwapped, beforeCapture) {
10832
10766
  return true;
10833
10767
  }
10834
10768
  /**
10835
- * Resolve a navigable internal URL from a click event, or `undefined` when the
10836
- * click should not be intercepted (modifier keys, non-anchor, external, new-tab).
10769
+ * Resolve the internal URL a link leads to, or `undefined` when the SPA leaves it
10770
+ * to the browser (no anchor, `target="_blank"`, another origin, a static asset). The
10771
+ * one link rule shared by the click interceptor and the preloader (`prefetch.ts`).
10837
10772
  *
10838
- * @param event - The click event to inspect.
10839
- * @returns The internal URL to navigate to, or `undefined`.
10773
+ * @param anchor - The link element, or nothing.
10774
+ * @returns The internal URL, or `undefined`.
10840
10775
  * @example
10841
- * const url = resolveClickTarget(event);
10776
+ * resolveLinkTarget(document.querySelector("a[href='/about']"))?.pathname; // "/about"
10842
10777
  */
10843
- function resolveClickTarget(event) {
10844
- if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return void 0;
10845
- if (event.defaultPrevented) return void 0;
10846
- const anchor = event.target?.closest("a");
10778
+ function resolveLinkTarget(anchor) {
10847
10779
  if (!anchor || anchor.target === "_blank") return void 0;
10848
10780
  let url;
10849
10781
  try {
@@ -10854,6 +10786,20 @@ function resolveClickTarget(event) {
10854
10786
  return isInternalLink(url) ? url : void 0;
10855
10787
  }
10856
10788
  /**
10789
+ * Resolve a navigable internal URL from a click event, or `undefined` when the
10790
+ * click should not be intercepted (modifier keys, non-anchor, external, new-tab).
10791
+ *
10792
+ * @param event - The click event to inspect.
10793
+ * @returns The internal URL to navigate to, or `undefined`.
10794
+ * @example
10795
+ * const url = resolveClickTarget(event);
10796
+ */
10797
+ function resolveClickTarget(event) {
10798
+ if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return void 0;
10799
+ if (event.defaultPrevented) return void 0;
10800
+ return resolveLinkTarget(event.target?.closest("a"));
10801
+ }
10802
+ /**
10857
10803
  * Attach the History-API click/popstate interception path (used when the
10858
10804
  * Navigation API is unavailable).
10859
10805
  *
@@ -10986,11 +10932,412 @@ function attachRouter(handlers, navigate, isOwnPush) {
10986
10932
  return navigation ? attachNavigationApi(navigation, handlers, navigate, isOwnPush) : attachHistoryFallback(handlers, navigate);
10987
10933
  }
10988
10934
  //#endregion
10935
+ //#region src/plugins/spa/prefetch.ts
10936
+ /**
10937
+ * @file spa plugin — opt-in in-memory HTML preload cache (`spa.prefetch`).
10938
+ * @see README.md
10939
+ */
10940
+ const HOVER_DELAY_MS = 65;
10941
+ const TTL_MS = 3e4;
10942
+ const MAX_ENTRIES = 20;
10943
+ const VISIBLE_CONCURRENCY = 2;
10944
+ const IDLE_FALLBACK_MS = 200;
10945
+ const SLOW_CONNECTION = /(^|-)2g$/;
10946
+ const PRESS = {
10947
+ capture: true,
10948
+ passive: true
10949
+ };
10950
+ const HOVER = { passive: true };
10951
+ /**
10952
+ * Read `navigator.connection` (mirrors `getNavigation()` in `router.ts`).
10953
+ *
10954
+ * @returns The connection info, or `undefined` where the browser has none.
10955
+ * @example
10956
+ * getConnection()?.saveData; // true with data saving on
10957
+ */
10958
+ function getConnection() {
10959
+ return navigator.connection;
10960
+ }
10961
+ /**
10962
+ * Whether no preload may start now: data saving on, or a 2G connection.
10963
+ *
10964
+ * @returns True when preloads must wait.
10965
+ * @example
10966
+ * isConstrained(); // true on effectiveType "slow-2g"
10967
+ */
10968
+ function isConstrained() {
10969
+ const connection = getConnection();
10970
+ return connection?.saveData === true || SLOW_CONNECTION.test(connection?.effectiveType ?? "");
10971
+ }
10972
+ /**
10973
+ * The link an event happened on.
10974
+ *
10975
+ * @param event - A press or mouse event.
10976
+ * @returns The closest `<a>`, or nothing.
10977
+ * @example
10978
+ * anchorOf(event); // <a href="/about"> for a tap on the <span> inside it
10979
+ */
10980
+ function anchorOf(event) {
10981
+ return event.target instanceof Element ? event.target.closest("a") : void 0;
10982
+ }
10983
+ /**
10984
+ * Run `task` in idle time, or after 200 ms where `requestIdleCallback` is missing.
10985
+ *
10986
+ * @param task - The work to run.
10987
+ * @returns Cancels the task.
10988
+ * @example
10989
+ * const cancel = whenIdle(drain); // cancel() before idle time: drain never runs
10990
+ */
10991
+ function whenIdle(task) {
10992
+ if (typeof requestIdleCallback === "function") {
10993
+ const handle = requestIdleCallback(task);
10994
+ return () => cancelIdleCallback(handle);
10995
+ }
10996
+ const handle = setTimeout(task, IDLE_FALLBACK_MS);
10997
+ return () => clearTimeout(handle);
10998
+ }
10999
+ /**
11000
+ * Build the preloader: press and hover listeners on `document` at once, and for
11001
+ * `"visible"` one IntersectionObserver that {@link Prefetcher.observe} feeds.
11002
+ *
11003
+ * @param options - The mode, the HTML-path check and the clock.
11004
+ * @returns The live preloader.
11005
+ * @example
11006
+ * createPrefetcher({ mode: "intent", usesHtml: () => true }).take("/about"); // undefined
11007
+ */
11008
+ function createPrefetcher(options) {
11009
+ const now = options.now ?? Date.now;
11010
+ const cache = /* @__PURE__ */ new Map();
11011
+ const hoverTimers = /* @__PURE__ */ new Map();
11012
+ const queue = [];
11013
+ let visibleInFlight = 0;
11014
+ let cancelDrain;
11015
+ /**
11016
+ * Whether an entry is older than the TTL.
11017
+ *
11018
+ * @param entry - A cache entry.
11019
+ * @returns True when the entry must not be used.
11020
+ * @example
11021
+ * isExpired(entry); // true 30_001 ms after it started
11022
+ */
11023
+ const isExpired = (entry) => now() - entry.startedAt > TTL_MS;
11024
+ /**
11025
+ * Abort an entry's request and remove it.
11026
+ *
11027
+ * @param key - The entry's path.
11028
+ * @param entry - The entry.
11029
+ * @example
11030
+ * drop("/a/", entry);
11031
+ */
11032
+ const drop = (key, entry) => {
11033
+ entry.controller.abort();
11034
+ cache.delete(key);
11035
+ };
11036
+ /**
11037
+ * The path a link would preload, or `undefined` when it is not eligible.
11038
+ *
11039
+ * @param anchor - The link, or nothing.
11040
+ * @returns The path (pathname + search), or `undefined`.
11041
+ * @example
11042
+ * pathOf(document.querySelector("a[href='/about?x=1#top']")); // "/about?x=1"
11043
+ */
11044
+ const pathOf = (anchor) => {
11045
+ const url = resolveLinkTarget(anchor);
11046
+ if (!url || anchor?.hasAttribute("download")) return void 0;
11047
+ const path = pathWithSearch(url);
11048
+ return path !== pathWithSearch(location) && options.usesHtml(path) ? path : void 0;
11049
+ };
11050
+ /**
11051
+ * Start one preload, unless the connection forbids it or the path is already cached.
11052
+ *
11053
+ * @param path - The page path.
11054
+ * @returns The new entry, or `undefined` when nothing started.
11055
+ * @example
11056
+ * start("/about"); // { promise, startedAt, controller }
11057
+ */
11058
+ const start = (path) => {
11059
+ if (isConstrained()) return void 0;
11060
+ for (const [key, entry] of cache) if (isExpired(entry)) drop(key, entry);
11061
+ if (cache.has(path)) return void 0;
11062
+ for (const [key, entry] of cache) {
11063
+ if (cache.size < MAX_ENTRIES) break;
11064
+ drop(key, entry);
11065
+ }
11066
+ const controller = new AbortController();
11067
+ const promise = fetchPage(path, controller.signal);
11068
+ const entry = {
11069
+ promise,
11070
+ startedAt: now(),
11071
+ controller
11072
+ };
11073
+ cache.set(path, entry);
11074
+ promise.catch(() => {
11075
+ if (cache.get(path) === entry) cache.delete(path);
11076
+ });
11077
+ return entry;
11078
+ };
11079
+ /**
11080
+ * Preload a link when it is eligible.
11081
+ *
11082
+ * @param anchor - The link, or nothing.
11083
+ * @example
11084
+ * preloadLink(document.querySelector("a"));
11085
+ */
11086
+ const preloadLink = (anchor) => {
11087
+ const path = pathOf(anchor);
11088
+ if (path !== void 0) start(path);
11089
+ };
11090
+ /**
11091
+ * Start queued `"visible"` preloads while fewer than 2 are in flight.
11092
+ *
11093
+ * @example
11094
+ * cancelDrain = whenIdle(drain);
11095
+ */
11096
+ const drain = () => {
11097
+ cancelDrain = void 0;
11098
+ while (visibleInFlight < VISIBLE_CONCURRENCY) {
11099
+ const path = queue.shift();
11100
+ if (path === void 0) return;
11101
+ const entry = start(path);
11102
+ if (!entry) continue;
11103
+ visibleInFlight += 1;
11104
+ entry.promise.catch(() => "").then(() => {
11105
+ visibleInFlight -= 1;
11106
+ scheduleDrain();
11107
+ });
11108
+ }
11109
+ };
11110
+ /**
11111
+ * Drain the queue in idle time, once.
11112
+ *
11113
+ * @example
11114
+ * scheduleDrain();
11115
+ */
11116
+ const scheduleDrain = () => {
11117
+ if (cancelDrain === void 0 && queue.length > 0) cancelDrain = whenIdle(drain);
11118
+ };
11119
+ /**
11120
+ * `pointerdown` / `touchstart`: preload at once.
11121
+ *
11122
+ * @param event - The press.
11123
+ * @example
11124
+ * document.addEventListener("pointerdown", onPress, PRESS);
11125
+ */
11126
+ const onPress = (event) => {
11127
+ preloadLink(anchorOf(event));
11128
+ };
11129
+ /**
11130
+ * `mouseover`: preload after 65 ms on the link.
11131
+ *
11132
+ * @param event - The mouse event.
11133
+ * @example
11134
+ * document.addEventListener("mouseover", onOver, HOVER);
11135
+ */
11136
+ const onOver = (event) => {
11137
+ const anchor = anchorOf(event);
11138
+ if (!anchor || hoverTimers.has(anchor)) return;
11139
+ const timer = setTimeout(() => {
11140
+ hoverTimers.delete(anchor);
11141
+ preloadLink(anchor);
11142
+ }, HOVER_DELAY_MS);
11143
+ hoverTimers.set(anchor, timer);
11144
+ };
11145
+ /**
11146
+ * `mouseout`: cancel the hover timer, unless the mouse only moved inside the same link.
11147
+ *
11148
+ * @param event - The mouse event.
11149
+ * @example
11150
+ * document.addEventListener("mouseout", onOut, HOVER);
11151
+ */
11152
+ const onOut = (event) => {
11153
+ const anchor = anchorOf(event);
11154
+ const into = event.relatedTarget;
11155
+ if (!anchor || into instanceof Node && anchor.contains(into)) return;
11156
+ clearTimeout(hoverTimers.get(anchor));
11157
+ hoverTimers.delete(anchor);
11158
+ };
11159
+ const observer = options.mode === "visible" && typeof IntersectionObserver === "function" ? new IntersectionObserver((entries, self) => {
11160
+ for (const entry of entries) {
11161
+ if (!entry.isIntersecting) continue;
11162
+ self.unobserve(entry.target);
11163
+ const path = pathOf(entry.target.closest("a"));
11164
+ if (path !== void 0) queue.push(path);
11165
+ }
11166
+ scheduleDrain();
11167
+ }) : void 0;
11168
+ document.addEventListener("pointerdown", onPress, PRESS);
11169
+ document.addEventListener("touchstart", onPress, PRESS);
11170
+ document.addEventListener("mouseover", onOver, HOVER);
11171
+ document.addEventListener("mouseout", onOut, HOVER);
11172
+ return {
11173
+ /**
11174
+ * Take the preload for `path` and remove it (see {@link Prefetcher.take}).
11175
+ *
11176
+ * @param path - The page path.
11177
+ * @returns The page HTML promise, or `undefined`.
11178
+ * @example
11179
+ * prefetcher.take("/about");
11180
+ */
11181
+ take(path) {
11182
+ const entry = cache.get(path);
11183
+ if (!entry) return void 0;
11184
+ if (isExpired(entry)) {
11185
+ drop(path, entry);
11186
+ return;
11187
+ }
11188
+ cache.delete(path);
11189
+ return entry.promise;
11190
+ },
11191
+ /**
11192
+ * Watch every eligible link in the document (see {@link Prefetcher.observe}).
11193
+ *
11194
+ * @example
11195
+ * prefetcher.observe();
11196
+ */
11197
+ observe() {
11198
+ if (!observer) return;
11199
+ observer.disconnect();
11200
+ queue.length = 0;
11201
+ for (const anchor of document.querySelectorAll("a[href]")) if (pathOf(anchor) !== void 0) observer.observe(anchor);
11202
+ },
11203
+ /**
11204
+ * Release everything (see {@link Prefetcher.dispose}).
11205
+ *
11206
+ * @example
11207
+ * prefetcher.dispose();
11208
+ */
11209
+ dispose() {
11210
+ document.removeEventListener("pointerdown", onPress, PRESS);
11211
+ document.removeEventListener("touchstart", onPress, PRESS);
11212
+ document.removeEventListener("mouseover", onOver, HOVER);
11213
+ document.removeEventListener("mouseout", onOut, HOVER);
11214
+ observer?.disconnect();
11215
+ for (const timer of hoverTimers.values()) clearTimeout(timer);
11216
+ hoverTimers.clear();
11217
+ cancelDrain?.();
11218
+ cancelDrain = void 0;
11219
+ queue.length = 0;
11220
+ for (const [key, entry] of cache) drop(key, entry);
11221
+ }
11222
+ };
11223
+ }
11224
+ /**
11225
+ * The HTML loader the kernel hands to `performNavigation`: a preloaded page when there is
11226
+ * one, else a normal fetch. A failed preload falls through to one real fetch, so it never
11227
+ * leads to a full reload; only a failed real fetch does, as without preloading.
11228
+ *
11229
+ * @param prefetcher - The live preloader, or `null` (mode `false`, or stopped).
11230
+ * @param path - The page path (pathname + search).
11231
+ * @param signal - The navigation's abort signal; never passed to the preload.
11232
+ * @returns The page HTML.
11233
+ * @throws {Error} When the real fetch fails, or the navigation was aborted after its preload failed.
11234
+ * @example
11235
+ * await loadWithPrefetch(state.prefetch, "/about", signal); // the preloaded HTML, no new request
11236
+ */
11237
+ async function loadWithPrefetch(prefetcher, path, signal) {
11238
+ const hit = prefetcher?.take(path);
11239
+ if (hit) try {
11240
+ return await hit;
11241
+ } catch {
11242
+ signal?.throwIfAborted();
11243
+ }
11244
+ return fetchPage(path, signal);
11245
+ }
11246
+ //#endregion
11247
+ //#region src/plugins/spa/progress.ts
11248
+ /** Delay before the bar appears, so fast navigations show no indicator. */
11249
+ const START_DELAY_MS = 150;
11250
+ /** Interval between trickle increments while loading. */
11251
+ const TRICKLE_MS = 300;
11252
+ /** Linger before the completed bar is reset/hidden. */
11253
+ const DONE_LINGER_MS = 200;
11254
+ /** Ceiling the bar trickles to while still loading (never reaches 100% until done). */
11255
+ const TRICKLE_CEIL = 90;
11256
+ /** No-op progress bar used when disabled or in a headless context. */
11257
+ const NOOP_BAR = {
11258
+ start() {},
11259
+ done() {}
11260
+ };
11261
+ /**
11262
+ * Creates the in-house progress bar (150ms delay + trickle). A no-op shell when
11263
+ * progress is disabled or no DOM is present. The progress element is created
11264
+ * once (prepended to `<body>` as `<div data-progress>`) and reused across navs.
11265
+ *
11266
+ * @param enabled - Whether the progress bar is active.
11267
+ * @returns A {@link ProgressBar} with `start`/`done`. Disabled/headless → no-ops.
11268
+ * @example
11269
+ * const bar = createProgressBar(true);
11270
+ * bar.start();
11271
+ * bar.done();
11272
+ */
11273
+ function createProgressBar(enabled) {
11274
+ if (!enabled || typeof document === "undefined") return NOOP_BAR;
11275
+ const element = document.createElement("div");
11276
+ element.dataset.progress = "";
11277
+ document.body.prepend(element);
11278
+ let delayTimer;
11279
+ let trickleTimer;
11280
+ let width = 0;
11281
+ /**
11282
+ * Step the trickle upward toward the ceiling and reschedule.
11283
+ *
11284
+ * @example
11285
+ * trickle();
11286
+ */
11287
+ const trickle = () => {
11288
+ if (width >= TRICKLE_CEIL) return;
11289
+ width = Math.min(TRICKLE_CEIL, width + 5 + Math.random() * 10);
11290
+ element.style.width = `${String(width)}%`;
11291
+ trickleTimer = setTimeout(trickle, TRICKLE_MS);
11292
+ };
11293
+ /**
11294
+ * Show the bar after the start delay and begin trickling.
11295
+ *
11296
+ * @example
11297
+ * start();
11298
+ */
11299
+ const start = () => {
11300
+ delayTimer = setTimeout(() => {
11301
+ width = 15;
11302
+ element.style.width = "15%";
11303
+ element.dataset.active = "";
11304
+ trickle();
11305
+ }, START_DELAY_MS);
11306
+ };
11307
+ /**
11308
+ * Complete the bar to 100%, then reset/hide it after a short linger.
11309
+ *
11310
+ * @example
11311
+ * done();
11312
+ */
11313
+ const done = () => {
11314
+ clearTimeout(delayTimer);
11315
+ clearTimeout(trickleTimer);
11316
+ element.style.width = "100%";
11317
+ element.dataset.active = "";
11318
+ setTimeout(() => {
11319
+ delete element.dataset.active;
11320
+ element.style.width = "0%";
11321
+ width = 0;
11322
+ }, DONE_LINGER_MS);
11323
+ };
11324
+ return {
11325
+ start,
11326
+ done
11327
+ };
11328
+ }
11329
+ //#endregion
10989
11330
  //#region src/plugins/spa/state.ts
10990
11331
  /** Error prefix for spa config-validation failures (spec/11 Part-3). */
10991
11332
  const ERROR_PREFIX$1 = "[web]";
10992
11333
  /** Last-resort `swapSelector` when neither config nor defaults supply one. */
10993
11334
  const FALLBACK_SWAP_SELECTOR = "main > section";
11335
+ /** Every value `spa.prefetch` accepts. */
11336
+ const PREFETCH_MODES = new Set([
11337
+ false,
11338
+ "intent",
11339
+ "visible"
11340
+ ]);
10994
11341
  /**
10995
11342
  * Resolve the `viewTransitions` config value into the default {@link TransitionMode}:
10996
11343
  * `true` → `"crossfade"`, `false` → `"none"`, a named mode → itself.
@@ -11005,13 +11352,28 @@ function resolveDefaultTransition(raw) {
11005
11352
  if (raw === false) return "none";
11006
11353
  return raw;
11007
11354
  }
11355
+ /**
11356
+ * Validate `spa.prefetch` (spec/11 Part-3 error). An untyped config can carry any value,
11357
+ * so a typo like `"hover"` fails at init instead of silently disabling the preload.
11358
+ *
11359
+ * @param mode - The raw `prefetch` config value (absent ⇒ `false`).
11360
+ * @returns The validated mode.
11361
+ * @throws {Error} When the value is not `false`, `"intent"` or `"visible"`.
11362
+ * @example
11363
+ * resolvePrefetchMode(undefined); // false
11364
+ */
11365
+ function resolvePrefetchMode(mode = false) {
11366
+ 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.`);
11367
+ return mode;
11368
+ }
11008
11369
  /** Default SPA config (declared as a value — no inline assertion). */
11009
11370
  const defaultSpaConfig = {
11010
11371
  swapSelector: "main > section",
11011
11372
  viewTransitions: false,
11012
11373
  scrollRestoration: "top",
11013
11374
  progressBar: true,
11014
- islands: []
11375
+ islands: [],
11376
+ prefetch: false
11015
11377
  };
11016
11378
  /**
11017
11379
  * Whether a selector is syntactically valid (parseable by the DOM). Falls back
@@ -11038,7 +11400,8 @@ function isValidSelector(selector) {
11038
11400
  *
11039
11401
  * @param config - The raw spa config to validate.
11040
11402
  * @returns The fully-resolved config with defaults applied.
11041
- * @throws {Error} When `swapSelector` is empty or not a valid CSS selector.
11403
+ * @throws {Error} When `swapSelector` is empty or not a valid CSS selector, or `prefetch`
11404
+ * is not one of its three values.
11042
11405
  * @example
11043
11406
  * const resolved = resolveSpaConfig({ swapSelector: "main > section" });
11044
11407
  */
@@ -11053,7 +11416,8 @@ function resolveSpaConfig(config) {
11053
11416
  defaultTransition,
11054
11417
  scrollRestoration: config.scrollRestoration ?? "top",
11055
11418
  progressBar: config.progressBar ?? true,
11056
- islands: config.islands ?? []
11419
+ islands: config.islands ?? [],
11420
+ prefetch: resolvePrefetchMode(config.prefetch)
11057
11421
  };
11058
11422
  }
11059
11423
  /**
@@ -11077,7 +11441,8 @@ function createState(_ctx) {
11077
11441
  destroyRouter: null,
11078
11442
  started: false,
11079
11443
  navigate: null,
11080
- kernel: null
11444
+ kernel: null,
11445
+ prefetch: null
11081
11446
  };
11082
11447
  }
11083
11448
  //#endregion
@@ -11115,6 +11480,21 @@ function currentLocationUrl() {
11115
11480
  return location.pathname + location.search;
11116
11481
  }
11117
11482
  /**
11483
+ * Whether a navigation to `path` renders from fetched HTML, so a preload of it is used.
11484
+ *
11485
+ * @param deps - The router and the optional data reader.
11486
+ * @param path - The page path (pathname + search).
11487
+ * @returns False when the client DATA path renders the page.
11488
+ * @example
11489
+ * usesHtml({ router, head }, "/en/about/"); // true: no data reader, every page is HTML
11490
+ */
11491
+ function usesHtml(deps, path) {
11492
+ if (deps.router.mode() === "ssg") return true;
11493
+ const route = deps.router.match(path.split("?")[0] ?? path)?.route;
11494
+ if (!route?._handlers.render) return true;
11495
+ return !isClientOnlyRoute(deps.router.mode(), route) && !deps.dataAt;
11496
+ }
11497
+ /**
11118
11498
  * Apply the matched route's `head` config to the live document (minimal client
11119
11499
  * head-sync for the DATA path: title only — the full meta sync runs on the
11120
11500
  * HTML-over-fetch path from the fetched `<head>`). The title is resolved through
@@ -11274,6 +11654,7 @@ function createSpaKernel(state, config, emit, deps) {
11274
11654
  const routeSlice = islandRouteContext(pathname);
11275
11655
  scanAndMount(state, emit, resolved.swapSelector, routeSlice);
11276
11656
  notifyNavEnd(state, routeSlice);
11657
+ state.prefetch?.observe();
11277
11658
  }, applyPendingScroll)) {
11278
11659
  handleError();
11279
11660
  location.href = pathname;
@@ -11388,6 +11769,7 @@ function createSpaKernel(state, config, emit, deps) {
11388
11769
  renderVNode(vnode, region);
11389
11770
  scanAndMount(state, emit, resolved.swapSelector, routeSlice);
11390
11771
  notifyNavEnd(state, routeSlice);
11772
+ state.prefetch?.observe();
11391
11773
  };
11392
11774
  runSwap(renderAndMount, pendingTransition, applyPendingScroll);
11393
11775
  state.currentUrl = pathname;
@@ -11436,12 +11818,14 @@ function createSpaKernel(state, config, emit, deps) {
11436
11818
  const resolvedRender = await resolveDataRender(pathname);
11437
11819
  if (resolvedRender === false) {
11438
11820
  scanAndMount(state, emit, resolved.swapSelector, routeSlice);
11821
+ state.prefetch?.observe();
11439
11822
  return;
11440
11823
  }
11441
11824
  const { vnode, region } = resolvedRender;
11442
11825
  const { renderVNode } = await Promise.resolve().then(() => require("./render-rf8FAg_Z.cjs"));
11443
11826
  renderVNode(vnode, region);
11444
11827
  scanAndMount(state, emit, resolved.swapSelector, routeSlice);
11828
+ state.prefetch?.observe();
11445
11829
  };
11446
11830
  /**
11447
11831
  * Unified navigation: try the client DATA path first (only when the `data`
@@ -11465,7 +11849,7 @@ function createSpaKernel(state, config, emit, deps) {
11465
11849
  handleStart(pathname);
11466
11850
  if (deps.router.mode() !== "ssg" && await tryDataRender(pathname, signal)) return;
11467
11851
  if (signal?.aborted) return;
11468
- await performNavigation(pathname, handlers, signal);
11852
+ await performNavigation(pathname, handlers, signal, (path, loadSignal) => loadWithPrefetch(state.prefetch, path, loadSignal));
11469
11853
  };
11470
11854
  /**
11471
11855
  * Programmatic navigation entry (`app.spa.navigate` / `ctx.navigate`). A link click or the
@@ -11533,10 +11917,17 @@ function createSpaKernel(state, config, emit, deps) {
11533
11917
  supersedeProgrammatic();
11534
11918
  return navigate(pathname, scrollToTop, signal, scrollOverride);
11535
11919
  }, () => pushingOwnUrl);
11920
+ if (resolved.prefetch !== false) state.prefetch = createPrefetcher({
11921
+ mode: resolved.prefetch,
11922
+ usesHtml: (path) => usesHtml(deps, path)
11923
+ });
11536
11924
  const matchPath = state.currentUrl.split("?")[0] ?? state.currentUrl;
11537
11925
  const hit = deps.router.match(matchPath);
11538
11926
  if (hit?.route._handlers.render && isClientOnlyRoute(deps.router.mode(), hit.route)) bootRender(state.currentUrl);
11539
- else scanAndMount(state, emit, resolved.swapSelector, islandRouteContext(state.currentUrl));
11927
+ else {
11928
+ scanAndMount(state, emit, resolved.swapSelector, islandRouteContext(state.currentUrl));
11929
+ state.prefetch?.observe();
11930
+ }
11540
11931
  state.started = true;
11541
11932
  },
11542
11933
  /**
@@ -11576,6 +11967,8 @@ function createSpaKernel(state, config, emit, deps) {
11576
11967
  if (typeof document === "undefined") return;
11577
11968
  state.destroyRouter?.();
11578
11969
  state.destroyRouter = null;
11970
+ state.prefetch?.dispose();
11971
+ state.prefetch = null;
11579
11972
  location.assign(url);
11580
11973
  },
11581
11974
  /**
@@ -11596,6 +11989,8 @@ function createSpaKernel(state, config, emit, deps) {
11596
11989
  dispose() {
11597
11990
  state.destroyRouter?.();
11598
11991
  state.destroyRouter = null;
11992
+ state.prefetch?.dispose();
11993
+ state.prefetch = null;
11599
11994
  unmountAll(state, emit);
11600
11995
  progress = void 0;
11601
11996
  state.started = false;