@very-simple/ajax 1.0.0-beta.6 → 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 +21 -2
- package/dist/index.d.ts +27 -8
- package/dist/index.js +120 -15
- package/package.json +1 -1
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
|
-
//
|
|
66
|
-
|
|
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,6 +93,19 @@ start({
|
|
|
87
93
|
})
|
|
88
94
|
```
|
|
89
95
|
|
|
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:
|
|
100
|
+
|
|
101
|
+
```html
|
|
102
|
+
<a href="/partial" data-ajax-state='{"isPartial": true}'>Partial</a>
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
```ts
|
|
106
|
+
start({ head: ({ to }) => (to.state?.isPartial ? 'append' : 'merge') })
|
|
107
|
+
```
|
|
108
|
+
|
|
90
109
|
### `visit(url, options?)`
|
|
91
110
|
|
|
92
111
|
A lower-level API for triggering a visit programmatically, e.g. in
|
package/dist/index.d.ts
CHANGED
|
@@ -17,15 +17,21 @@ 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
|
-
/**
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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>;
|
|
31
|
+
/** Re-execute `<script>` tags found in the swapped content. A callback decides per visit. */
|
|
32
|
+
executeScripts?: Resolvable<boolean>;
|
|
33
|
+
/** Wrap navigations in the View Transitions API, if supported. A callback decides per visit. */
|
|
34
|
+
viewTransitions?: Resolvable<boolean>;
|
|
29
35
|
/** Called instead of the default merge to render the new document. */
|
|
30
36
|
render?: (newDocument: Document) => Promise<void> | void;
|
|
31
37
|
/** Custom scroll handling, e.g. restoring position on back/forward visits. */
|
|
@@ -38,11 +44,24 @@ 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. */
|
|
44
57
|
unmount?: (el: Element) => void;
|
|
45
58
|
}
|
|
59
|
+
/** A value, or a callback resolving it right before the swap. */
|
|
60
|
+
type Resolvable<T> = T | ((info: {
|
|
61
|
+
from: PageState;
|
|
62
|
+
to: PageState;
|
|
63
|
+
isBackForward: boolean;
|
|
64
|
+
}) => T);
|
|
46
65
|
type ScrollPosition = {
|
|
47
66
|
top: number;
|
|
48
67
|
};
|
|
@@ -95,6 +114,6 @@ declare const scrollPositions: Map<string, ScrollPosition>;
|
|
|
95
114
|
* Load a new page, merge the regions/bodies and add a new history entry
|
|
96
115
|
* according to the history mode.
|
|
97
116
|
*/
|
|
98
|
-
declare const visit: (url: string, { history: historyMode, isBackForward, autoFocus, request, state, regions,
|
|
117
|
+
declare const visit: (url: string, { history: historyMode, isBackForward, autoFocus, request, state, regions, head, merge: mergeStrategy, executeScripts: executeScriptsOption, viewTransitions, loadingDelay, progressHideDelay, }?: VisitOptions) => Promise<void>;
|
|
99
118
|
|
|
100
|
-
export { type AjaxState, type Config, type EventMap, type LoadingOptions, type MergeStrategy, type PageState, 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
|
-
|
|
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
|
|
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,9 +262,9 @@ var visit = async (url, {
|
|
|
229
262
|
request,
|
|
230
263
|
state,
|
|
231
264
|
regions = config.regions,
|
|
232
|
-
|
|
265
|
+
head = config.head,
|
|
233
266
|
merge: mergeStrategy = config.merge,
|
|
234
|
-
executeScripts:
|
|
267
|
+
executeScripts: executeScriptsOption = config.executeScripts,
|
|
235
268
|
viewTransitions = config.viewTransitions,
|
|
236
269
|
loadingDelay = config.loadingDelay,
|
|
237
270
|
progressHideDelay = config.progressHideDelay
|
|
@@ -274,6 +307,19 @@ var visit = async (url, {
|
|
|
274
307
|
const to = { url, state, document: newDocument };
|
|
275
308
|
await emit2("before-swap", { from, to, isBackForward, signal });
|
|
276
309
|
if (signal.aborted) return;
|
|
310
|
+
const resolve = (option) => typeof option === "function" ? option({ from, to, isBackForward }) : option;
|
|
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
|
+
};
|
|
321
|
+
const shouldExecuteScripts = resolve(executeScriptsOption);
|
|
322
|
+
const useViewTransitions = resolve(viewTransitions);
|
|
277
323
|
const getMergeStrategy = (oldEl, newEl) => newEl.getAttribute(`${config.prefix}merge`) || oldEl.getAttribute(`${config.prefix}merge`) || mergeStrategy;
|
|
278
324
|
let autoFocusEl;
|
|
279
325
|
const hasMatchingRegions = regions?.some(
|
|
@@ -320,17 +366,17 @@ var visit = async (url, {
|
|
|
320
366
|
if (position) window.scrollTo(position);
|
|
321
367
|
};
|
|
322
368
|
if (config.render) {
|
|
323
|
-
|
|
369
|
+
updateHead();
|
|
324
370
|
await config.render(newDocument);
|
|
325
|
-
} else if (
|
|
371
|
+
} else if (useViewTransitions && document.startViewTransition) {
|
|
326
372
|
await document.startViewTransition(async () => {
|
|
327
|
-
|
|
373
|
+
updateHead();
|
|
328
374
|
await mergeRegions();
|
|
329
375
|
await emit2("after-swap", { from, to, isBackForward, signal });
|
|
330
376
|
applyScrollBehavior();
|
|
331
377
|
}).updateCallbackDone;
|
|
332
378
|
} else {
|
|
333
|
-
|
|
379
|
+
updateHead();
|
|
334
380
|
await mergeRegions();
|
|
335
381
|
await emit2("after-swap", { from, to, isBackForward, signal });
|
|
336
382
|
applyScrollBehavior();
|
|
@@ -346,15 +392,73 @@ var interceptLinks = () => {
|
|
|
346
392
|
if (e.defaultPrevented || isModifiedClick(e)) return;
|
|
347
393
|
const target = e.composedPath ? e.composedPath()[0] : e.target;
|
|
348
394
|
const link = target?.closest?.("a");
|
|
349
|
-
if (!link || !link
|
|
350
|
-
const isOptOut = link.hasAttribute(`${config.prefix}reload`);
|
|
351
|
-
const isExternalTarget = link.target && link.target !== "_self";
|
|
352
|
-
if (isOptOut || link.hasAttribute("download") || isExternalTarget || !isSameOrigin(link.href))
|
|
353
|
-
return;
|
|
395
|
+
if (!link || !isInterceptable(link)) return;
|
|
354
396
|
e.preventDefault();
|
|
355
397
|
visit(link.href, { history: "push", ...getVisitOptions(link) });
|
|
356
398
|
});
|
|
357
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
|
+
};
|
|
358
462
|
var interceptForms = () => {
|
|
359
463
|
document.addEventListener("submit", (e) => {
|
|
360
464
|
const form = e.target;
|
|
@@ -425,6 +529,7 @@ var start = (options = {}) => {
|
|
|
425
529
|
interceptLinks();
|
|
426
530
|
interceptForms();
|
|
427
531
|
interceptHistory();
|
|
532
|
+
interceptPrefetch();
|
|
428
533
|
if (import.meta.env.DEV) {
|
|
429
534
|
document.addEventListener("ajax:before-visit", () => {
|
|
430
535
|
document.head.querySelectorAll("style[data-vite-dev-id]").forEach((el) => el.setAttribute("im-preserve", "true"));
|