@very-simple/ajax 1.0.0-beta.7 → 1.0.0-beta.8

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/README.md CHANGED
@@ -39,6 +39,7 @@ without writing any JavaScript:
39
39
  | `data-ajax-history` | History mode to use: `push`, `replace` or `none`. |
40
40
  | `data-ajax-merge` | Merge strategy for the swapped content, e.g. `morph`, `replace`, `before`, `append`. |
41
41
  | `data-ajax-state` | JSON encoded state to store in `history.state` for this visit. |
42
+ | `data-ajax-prefetch` | Prefetch the link before it's clicked: `hover`, `mousedown` or `false` to opt out. Without a value the `prefetch` option is used, falling back to `hover`. |
42
43
 
43
44
  ```html
44
45
  <!-- only swap #content, preserving the merge strategy for that region -->
@@ -62,8 +63,8 @@ import { start } from '@very-simple/ajax'
62
63
  start({
63
64
  // Strategy used to merge the swapped content into the current DOM.
64
65
  merge: 'replace',
65
- // Morph the `<head>` instead of leaving it untouched.
66
- morphHeads: true,
66
+ // How to update the `<head>`: 'merge', 'append', 'morph' or 'none'.
67
+ head: 'merge',
67
68
  // Re-execute `<script>` tags found in the swapped content.
68
69
  executeScripts: true,
69
70
  // Wrap navigations in the View Transitions API, if supported.
@@ -77,6 +78,11 @@ start({
77
78
  // Default selectors to swap instead of the whole `<body>`, used for visits
78
79
  // that don't specify their own `regions` (e.g. via `data-ajax-regions`).
79
80
  regions: [],
81
+ // Default prefetch strategy for links: false, 'hover' (hover/focus) or
82
+ // 'mousedown' (press). Touch always prefetches on touchstart.
83
+ prefetch: false,
84
+ // How long (ms) a prefetched response stays usable.
85
+ prefetchTtl: 30000,
80
86
  // Custom scroll handling, e.g. restoring position on back/forward visits.
81
87
  scrollBehavior: ({ isBackForward, savedPosition }) =>
82
88
  isBackForward ? savedPosition : { top: 0 },
@@ -87,17 +93,17 @@ start({
87
93
  })
88
94
  ```
89
95
 
90
- `morphHeads`, `executeScripts` and `viewTransitions` also accept a callback
91
- receiving `{ from, to, isBackForward }` and returning a boolean. Combined with
92
- `data-ajax-state` this allows deciding per link, e.g. to skip morphing the
93
- `<head>` when loading a partial:
96
+ `head`, `executeScripts` and `viewTransitions` also accept a callback
97
+ receiving `{ from, to, isBackForward }`. Combined with `data-ajax-state` this
98
+ allows deciding per link, e.g. to only add new `<head>` elements (scripts,
99
+ styles) without removing existing ones when loading a partial:
94
100
 
95
101
  ```html
96
102
  <a href="/partial" data-ajax-state='{"isPartial": true}'>Partial</a>
97
103
  ```
98
104
 
99
105
  ```ts
100
- start({ morphHeads: ({ to }) => !to.state?.isPartial })
106
+ start({ head: ({ to }) => (to.state?.isPartial ? 'append' : 'merge') })
101
107
  ```
102
108
 
103
109
  ### `visit(url, options?)`
package/dist/index.d.ts CHANGED
@@ -17,11 +17,17 @@ interface LoadingOptions {
17
17
  request?: RequestInit;
18
18
  }
19
19
  type MergeStrategy = 'replace' | 'morph' | 'before' | 'after' | 'prepend' | 'append' | 'update';
20
+ /** `true` is `'merge'`, `false` is `'none'`. */
21
+ type HeadMode = 'merge' | 'append' | 'morph' | 'none' | boolean;
20
22
  interface Config {
21
23
  /** Strategy used to merge the swapped content into the current DOM. */
22
24
  merge?: MergeStrategy;
23
- /** Morph the `<head>` instead of leaving it untouched. A callback decides per visit. */
24
- morphHeads?: Resolvable<boolean>;
25
+ /**
26
+ * How to update the `<head>`: `'merge'` adds new and removes stale elements,
27
+ * `'append'` only adds new ones, `'morph'` morphs it like any element and
28
+ * `'none'` leaves it untouched. A callback decides per visit.
29
+ */
30
+ head?: Resolvable<HeadMode>;
25
31
  /** Re-execute `<script>` tags found in the swapped content. A callback decides per visit. */
26
32
  executeScripts?: Resolvable<boolean>;
27
33
  /** Wrap navigations in the View Transitions API, if supported. A callback decides per visit. */
@@ -38,6 +44,13 @@ interface Config {
38
44
  prefix?: string;
39
45
  /** Default regions used for visits that don't specify their own. */
40
46
  regions?: string[];
47
+ /**
48
+ * Default prefetch strategy for links: `'hover'` on hover/focus, `'mousedown'`
49
+ * on press. Links can override it with `data-ajax-prefetch`.
50
+ */
51
+ prefetch?: false | 'hover' | 'mousedown';
52
+ /** How long (ms) a prefetched response stays usable. */
53
+ prefetchTtl?: number;
41
54
  /** Called after an element is added to the DOM by a merge. */
42
55
  mount?: (el: Element) => void;
43
56
  /** Called before an element is removed from the DOM by a merge. */
@@ -101,6 +114,6 @@ declare const scrollPositions: Map<string, ScrollPosition>;
101
114
  * Load a new page, merge the regions/bodies and add a new history entry
102
115
  * according to the history mode.
103
116
  */
104
- declare const visit: (url: string, { history: historyMode, isBackForward, autoFocus, request, state, regions, morphHeads, merge: mergeStrategy, executeScripts: executeScriptsOption, viewTransitions, loadingDelay, progressHideDelay, }?: VisitOptions) => Promise<void>;
117
+ declare const visit: (url: string, { history: historyMode, isBackForward, autoFocus, request, state, regions, head, merge: mergeStrategy, executeScripts: executeScriptsOption, viewTransitions, loadingDelay, progressHideDelay, }?: VisitOptions) => Promise<void>;
105
118
 
106
- export { type AjaxState, type Config, type EventMap, type LoadingOptions, type MergeStrategy, type PageState, type Resolvable, type ScrollBehavior, type ScrollPosition, type StartOptions, type VisitOptions, scrollPositions, start, visit };
119
+ export { type AjaxState, type Config, type EventMap, type HeadMode, type LoadingOptions, type MergeStrategy, type PageState, type Resolvable, type ScrollBehavior, type ScrollPosition, type StartOptions, type VisitOptions, scrollPositions, start, visit };
package/dist/index.js CHANGED
@@ -1,13 +1,45 @@
1
1
  // src/config.ts
2
2
  var config = {
3
- morphHeads: true,
3
+ head: "merge",
4
4
  merge: "replace",
5
5
  executeScripts: true,
6
6
  viewTransitions: false,
7
7
  loadingDelay: 500,
8
8
  progressHideDelay: 500,
9
9
  prefix: "data-ajax-",
10
- regions: []
10
+ regions: [],
11
+ prefetch: false,
12
+ prefetchTtl: 3e4
13
+ };
14
+
15
+ // src/prefetch.ts
16
+ var store = /* @__PURE__ */ new Map();
17
+ var keyOf = (url, regions) => `${url}
18
+ ${regions.join(" ")}`;
19
+ var isFresh = (time) => Date.now() - time < config.prefetchTtl;
20
+ var prefetch = (url, regions) => {
21
+ for (const [key2, entry] of store) if (!isFresh(entry.time)) store.delete(key2);
22
+ const key = keyOf(url, regions);
23
+ if (store.has(key)) return;
24
+ const promise = fetch(url, {
25
+ headers: {
26
+ "X-Very-Simple-Ajax": "true",
27
+ "X-Very-Simple-Ajax-Regions": regions.join(" ")
28
+ }
29
+ }).then(async (response) => {
30
+ if (!response.ok) return void 0;
31
+ return { html: await response.text(), response };
32
+ }).catch(() => void 0).then((result) => {
33
+ if (!result) store.delete(key);
34
+ return result;
35
+ });
36
+ store.set(key, { time: Date.now(), promise });
37
+ };
38
+ var takePrefetched = (url, regions) => {
39
+ const key = keyOf(url, regions);
40
+ const entry = store.get(key);
41
+ store.delete(key);
42
+ return entry && isFresh(entry.time) ? entry.promise : void 0;
11
43
  };
12
44
 
13
45
  // src/load.ts
@@ -29,7 +61,8 @@ var load = async (url, regions, options) => {
29
61
  emit("progress-start", {});
30
62
  startTrickle();
31
63
  }, options.loadingDelay);
32
- const response = await fetch(url, {
64
+ const prefetched = await takePrefetched(url, regions);
65
+ const response = prefetched?.response ?? await fetch(url, {
33
66
  ...options.request,
34
67
  headers: {
35
68
  ...options.request?.headers,
@@ -38,7 +71,7 @@ var load = async (url, regions, options) => {
38
71
  },
39
72
  signal: currentLoadController.signal
40
73
  });
41
- const html = await response.text();
74
+ const html = prefetched?.html ?? await response.text();
42
75
  if (response.ok) cache.set(url, html);
43
76
  const document2 = parseHtml(html);
44
77
  return { document: document2, response };
@@ -229,7 +262,7 @@ var visit = async (url, {
229
262
  request,
230
263
  state,
231
264
  regions = config.regions,
232
- morphHeads = config.morphHeads,
265
+ head = config.head,
233
266
  merge: mergeStrategy = config.merge,
234
267
  executeScripts: executeScriptsOption = config.executeScripts,
235
268
  viewTransitions = config.viewTransitions,
@@ -275,7 +308,16 @@ var visit = async (url, {
275
308
  await emit2("before-swap", { from, to, isBackForward, signal });
276
309
  if (signal.aborted) return;
277
310
  const resolve = (option) => typeof option === "function" ? option({ from, to, isBackForward }) : option;
278
- const shouldMorphHeads = resolve(morphHeads);
311
+ const resolvedHead = resolve(head);
312
+ let headStyle = resolvedHead;
313
+ if (headStyle === true) headStyle = "merge";
314
+ else if (headStyle === false) headStyle = "none";
315
+ const updateHead = () => {
316
+ if (headStyle === "none") return;
317
+ Idiomorph2.morph(document.head, newDocument.head, {
318
+ head: { style: headStyle }
319
+ });
320
+ };
279
321
  const shouldExecuteScripts = resolve(executeScriptsOption);
280
322
  const useViewTransitions = resolve(viewTransitions);
281
323
  const getMergeStrategy = (oldEl, newEl) => newEl.getAttribute(`${config.prefix}merge`) || oldEl.getAttribute(`${config.prefix}merge`) || mergeStrategy;
@@ -324,17 +366,17 @@ var visit = async (url, {
324
366
  if (position) window.scrollTo(position);
325
367
  };
326
368
  if (config.render) {
327
- if (shouldMorphHeads) Idiomorph2.morph(document.head, newDocument.head);
369
+ updateHead();
328
370
  await config.render(newDocument);
329
371
  } else if (useViewTransitions && document.startViewTransition) {
330
372
  await document.startViewTransition(async () => {
331
- if (shouldMorphHeads) Idiomorph2.morph(document.head, newDocument.head);
373
+ updateHead();
332
374
  await mergeRegions();
333
375
  await emit2("after-swap", { from, to, isBackForward, signal });
334
376
  applyScrollBehavior();
335
377
  }).updateCallbackDone;
336
378
  } else {
337
- if (shouldMorphHeads) Idiomorph2.morph(document.head, newDocument.head);
379
+ updateHead();
338
380
  await mergeRegions();
339
381
  await emit2("after-swap", { from, to, isBackForward, signal });
340
382
  applyScrollBehavior();
@@ -350,15 +392,73 @@ var interceptLinks = () => {
350
392
  if (e.defaultPrevented || isModifiedClick(e)) return;
351
393
  const target = e.composedPath ? e.composedPath()[0] : e.target;
352
394
  const link = target?.closest?.("a");
353
- if (!link || !link.href) return;
354
- const isOptOut = link.hasAttribute(`${config.prefix}reload`);
355
- const isExternalTarget = link.target && link.target !== "_self";
356
- if (isOptOut || link.hasAttribute("download") || isExternalTarget || !isSameOrigin(link.href))
357
- return;
395
+ if (!link || !isInterceptable(link)) return;
358
396
  e.preventDefault();
359
397
  visit(link.href, { history: "push", ...getVisitOptions(link) });
360
398
  });
361
399
  };
400
+ var isInterceptable = (link) => {
401
+ if (!link.href) return false;
402
+ const isExternalTarget = link.target && link.target !== "_self";
403
+ return !(link.hasAttribute(`${config.prefix}reload`) || link.hasAttribute("download") || isExternalTarget || !isSameOrigin(link.href));
404
+ };
405
+ var HOVER_DELAY = 65;
406
+ var getPrefetchStrategy = (link) => {
407
+ const attr = link.getAttribute(`${config.prefix}prefetch`);
408
+ if (attr === null) return config.prefetch;
409
+ if (attr === "false") return false;
410
+ if (attr === "hover" || attr === "mousedown") return attr;
411
+ return config.prefetch || "hover";
412
+ };
413
+ var interceptPrefetch = () => {
414
+ let hoverTimeout;
415
+ let hoverLink;
416
+ const run = (link) => {
417
+ if (navigator.connection?.saveData) return;
418
+ const url = normalizeUrl(link.href);
419
+ if (url === currentUrl) return;
420
+ const regions = getVisitOptions(link).regions ?? config.regions;
421
+ prefetch(url, regions);
422
+ };
423
+ const findLink = (e) => {
424
+ const link = e.target?.closest?.("a");
425
+ if (!link || !getPrefetchStrategy(link) || !isInterceptable(link)) return;
426
+ return link;
427
+ };
428
+ const clearHover = () => {
429
+ clearTimeout(hoverTimeout);
430
+ hoverLink = void 0;
431
+ };
432
+ document.addEventListener("mouseover", (e) => {
433
+ const link = findLink(e);
434
+ if (!link || link === hoverLink || getPrefetchStrategy(link) !== "hover")
435
+ return;
436
+ clearHover();
437
+ hoverLink = link;
438
+ hoverTimeout = window.setTimeout(() => run(link), HOVER_DELAY);
439
+ });
440
+ document.addEventListener("mouseout", (e) => {
441
+ const to = e.relatedTarget;
442
+ if (hoverLink && !(to && hoverLink.contains(to))) clearHover();
443
+ });
444
+ document.addEventListener("focusin", (e) => {
445
+ const link = findLink(e);
446
+ if (link && getPrefetchStrategy(link) === "hover") run(link);
447
+ });
448
+ document.addEventListener(
449
+ "touchstart",
450
+ (e) => {
451
+ const link = findLink(e);
452
+ if (link) run(link);
453
+ },
454
+ { passive: true }
455
+ );
456
+ document.addEventListener("mousedown", (e) => {
457
+ if (e.button !== 0) return;
458
+ const link = findLink(e);
459
+ if (link) run(link);
460
+ });
461
+ };
362
462
  var interceptForms = () => {
363
463
  document.addEventListener("submit", (e) => {
364
464
  const form = e.target;
@@ -429,6 +529,7 @@ var start = (options = {}) => {
429
529
  interceptLinks();
430
530
  interceptForms();
431
531
  interceptHistory();
532
+ interceptPrefetch();
432
533
  if (import.meta.env.DEV) {
433
534
  document.addEventListener("ajax:before-visit", () => {
434
535
  document.head.querySelectorAll("style[data-vite-dev-id]").forEach((el) => el.setAttribute("im-preserve", "true"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@very-simple/ajax",
3
- "version": "1.0.0-beta.7",
3
+ "version": "1.0.0-beta.8",
4
4
  "description": "A very simple turbolinks inspired ajax library",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",