@very-simple/ajax 1.0.0-beta.7 → 1.0.0-beta.9
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 +13 -7
- package/dist/index.d.ts +19 -5
- package/dist/index.js +130 -17
- 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,17 +93,17 @@ start({
|
|
|
87
93
|
})
|
|
88
94
|
```
|
|
89
95
|
|
|
90
|
-
`
|
|
91
|
-
receiving `{ from, to, isBackForward }
|
|
92
|
-
|
|
93
|
-
|
|
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({
|
|
106
|
+
start({ head: ({ to }) => (to.state?.isPartial ? 'append' : 'merge') })
|
|
101
107
|
```
|
|
102
108
|
|
|
103
109
|
### `visit(url, options?)`
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
interface AjaxState extends Record<string, unknown> {
|
|
2
2
|
regions?: string[];
|
|
3
|
+
index?: number;
|
|
3
4
|
}
|
|
4
5
|
/** Also accepts all `Config` options, e.g. `merge`, `scrollBehavior`, etc. */
|
|
5
6
|
interface VisitOptions extends Config {
|
|
@@ -8,7 +9,7 @@ interface VisitOptions extends Config {
|
|
|
8
9
|
autoFocus?: boolean;
|
|
9
10
|
request?: RequestInit;
|
|
10
11
|
regions?: string[];
|
|
11
|
-
state?: Omit<AjaxState, 'regions'>;
|
|
12
|
+
state?: Omit<AjaxState, 'regions' | 'index'>;
|
|
12
13
|
}
|
|
13
14
|
type StartOptions = Config;
|
|
14
15
|
interface LoadingOptions {
|
|
@@ -17,11 +18,17 @@ interface LoadingOptions {
|
|
|
17
18
|
request?: RequestInit;
|
|
18
19
|
}
|
|
19
20
|
type MergeStrategy = 'replace' | 'morph' | 'before' | 'after' | 'prepend' | 'append' | 'update';
|
|
21
|
+
/** `true` is `'merge'`, `false` is `'none'`. */
|
|
22
|
+
type HeadMode = 'merge' | 'append' | 'morph' | 'none' | boolean;
|
|
20
23
|
interface Config {
|
|
21
24
|
/** Strategy used to merge the swapped content into the current DOM. */
|
|
22
25
|
merge?: MergeStrategy;
|
|
23
|
-
/**
|
|
24
|
-
|
|
26
|
+
/**
|
|
27
|
+
* How to update the `<head>`: `'merge'` adds new and removes stale elements,
|
|
28
|
+
* `'append'` only adds new ones, `'morph'` morphs it like any element and
|
|
29
|
+
* `'none'` leaves it untouched. A callback decides per visit.
|
|
30
|
+
*/
|
|
31
|
+
head?: Resolvable<HeadMode>;
|
|
25
32
|
/** Re-execute `<script>` tags found in the swapped content. A callback decides per visit. */
|
|
26
33
|
executeScripts?: Resolvable<boolean>;
|
|
27
34
|
/** Wrap navigations in the View Transitions API, if supported. A callback decides per visit. */
|
|
@@ -38,6 +45,13 @@ interface Config {
|
|
|
38
45
|
prefix?: string;
|
|
39
46
|
/** Default regions used for visits that don't specify their own. */
|
|
40
47
|
regions?: string[];
|
|
48
|
+
/**
|
|
49
|
+
* Default prefetch strategy for links: `'hover'` on hover/focus, `'mousedown'`
|
|
50
|
+
* on press. Links can override it with `data-ajax-prefetch`.
|
|
51
|
+
*/
|
|
52
|
+
prefetch?: false | 'hover' | 'mousedown';
|
|
53
|
+
/** How long (ms) a prefetched response stays usable. */
|
|
54
|
+
prefetchTtl?: number;
|
|
41
55
|
/** Called after an element is added to the DOM by a merge. */
|
|
42
56
|
mount?: (el: Element) => void;
|
|
43
57
|
/** Called before an element is removed from the DOM by a merge. */
|
|
@@ -101,6 +115,6 @@ declare const scrollPositions: Map<string, ScrollPosition>;
|
|
|
101
115
|
* Load a new page, merge the regions/bodies and add a new history entry
|
|
102
116
|
* according to the history mode.
|
|
103
117
|
*/
|
|
104
|
-
declare const visit: (url: string, { history: historyMode, isBackForward, autoFocus, request, state, regions,
|
|
118
|
+
declare const visit: (url: string, { history: historyMode, isBackForward, autoFocus, request, state, regions, head, merge: mergeStrategy, executeScripts: executeScriptsOption, viewTransitions, loadingDelay, progressHideDelay, }?: VisitOptions) => Promise<void>;
|
|
105
119
|
|
|
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 };
|
|
120
|
+
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,7 +262,7 @@ 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
267
|
executeScripts: executeScriptsOption = config.executeScripts,
|
|
235
268
|
viewTransitions = config.viewTransitions,
|
|
@@ -239,7 +272,11 @@ var visit = async (url, {
|
|
|
239
272
|
url = normalizeUrl(url);
|
|
240
273
|
const fromUrl = currentUrl;
|
|
241
274
|
const fromState = currentState;
|
|
242
|
-
|
|
275
|
+
if (!(isBackForward && state)) {
|
|
276
|
+
const currentIndex = currentState?.index ?? 0;
|
|
277
|
+
const index = historyMode === "push" ? currentIndex + 1 : currentIndex;
|
|
278
|
+
state = { ...state, regions, index };
|
|
279
|
+
}
|
|
243
280
|
currentVisitController?.abort();
|
|
244
281
|
currentVisitController = new AbortController();
|
|
245
282
|
const { signal } = currentVisitController;
|
|
@@ -254,7 +291,13 @@ var visit = async (url, {
|
|
|
254
291
|
let newDocument;
|
|
255
292
|
if (isBackForward) {
|
|
256
293
|
const html = cache.get(url);
|
|
257
|
-
if (html)
|
|
294
|
+
if (html) {
|
|
295
|
+
const cached = parseHtml(html);
|
|
296
|
+
const isComplete = regions.every(
|
|
297
|
+
(selector) => !document.querySelector(selector) || cached.querySelector(selector)
|
|
298
|
+
);
|
|
299
|
+
if (isComplete) newDocument = cached;
|
|
300
|
+
}
|
|
258
301
|
}
|
|
259
302
|
if (!newDocument) {
|
|
260
303
|
const options = { loadingDelay, progressHideDelay, request };
|
|
@@ -275,7 +318,16 @@ var visit = async (url, {
|
|
|
275
318
|
await emit2("before-swap", { from, to, isBackForward, signal });
|
|
276
319
|
if (signal.aborted) return;
|
|
277
320
|
const resolve = (option) => typeof option === "function" ? option({ from, to, isBackForward }) : option;
|
|
278
|
-
const
|
|
321
|
+
const resolvedHead = resolve(head);
|
|
322
|
+
let headStyle = resolvedHead;
|
|
323
|
+
if (headStyle === true) headStyle = "merge";
|
|
324
|
+
else if (headStyle === false) headStyle = "none";
|
|
325
|
+
const updateHead = () => {
|
|
326
|
+
if (headStyle === "none") return;
|
|
327
|
+
Idiomorph2.morph(document.head, newDocument.head, {
|
|
328
|
+
head: { style: headStyle }
|
|
329
|
+
});
|
|
330
|
+
};
|
|
279
331
|
const shouldExecuteScripts = resolve(executeScriptsOption);
|
|
280
332
|
const useViewTransitions = resolve(viewTransitions);
|
|
281
333
|
const getMergeStrategy = (oldEl, newEl) => newEl.getAttribute(`${config.prefix}merge`) || oldEl.getAttribute(`${config.prefix}merge`) || mergeStrategy;
|
|
@@ -324,17 +376,17 @@ var visit = async (url, {
|
|
|
324
376
|
if (position) window.scrollTo(position);
|
|
325
377
|
};
|
|
326
378
|
if (config.render) {
|
|
327
|
-
|
|
379
|
+
updateHead();
|
|
328
380
|
await config.render(newDocument);
|
|
329
381
|
} else if (useViewTransitions && document.startViewTransition) {
|
|
330
382
|
await document.startViewTransition(async () => {
|
|
331
|
-
|
|
383
|
+
updateHead();
|
|
332
384
|
await mergeRegions();
|
|
333
385
|
await emit2("after-swap", { from, to, isBackForward, signal });
|
|
334
386
|
applyScrollBehavior();
|
|
335
387
|
}).updateCallbackDone;
|
|
336
388
|
} else {
|
|
337
|
-
|
|
389
|
+
updateHead();
|
|
338
390
|
await mergeRegions();
|
|
339
391
|
await emit2("after-swap", { from, to, isBackForward, signal });
|
|
340
392
|
applyScrollBehavior();
|
|
@@ -350,15 +402,73 @@ var interceptLinks = () => {
|
|
|
350
402
|
if (e.defaultPrevented || isModifiedClick(e)) return;
|
|
351
403
|
const target = e.composedPath ? e.composedPath()[0] : e.target;
|
|
352
404
|
const link = target?.closest?.("a");
|
|
353
|
-
if (!link || !link
|
|
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;
|
|
405
|
+
if (!link || !isInterceptable(link)) return;
|
|
358
406
|
e.preventDefault();
|
|
359
407
|
visit(link.href, { history: "push", ...getVisitOptions(link) });
|
|
360
408
|
});
|
|
361
409
|
};
|
|
410
|
+
var isInterceptable = (link) => {
|
|
411
|
+
if (!link.href) return false;
|
|
412
|
+
const isExternalTarget = link.target && link.target !== "_self";
|
|
413
|
+
return !(link.hasAttribute(`${config.prefix}reload`) || link.hasAttribute("download") || isExternalTarget || !isSameOrigin(link.href));
|
|
414
|
+
};
|
|
415
|
+
var HOVER_DELAY = 65;
|
|
416
|
+
var getPrefetchStrategy = (link) => {
|
|
417
|
+
const attr = link.getAttribute(`${config.prefix}prefetch`);
|
|
418
|
+
if (attr === null) return config.prefetch;
|
|
419
|
+
if (attr === "false") return false;
|
|
420
|
+
if (attr === "hover" || attr === "mousedown") return attr;
|
|
421
|
+
return config.prefetch || "hover";
|
|
422
|
+
};
|
|
423
|
+
var interceptPrefetch = () => {
|
|
424
|
+
let hoverTimeout;
|
|
425
|
+
let hoverLink;
|
|
426
|
+
const run = (link) => {
|
|
427
|
+
if (navigator.connection?.saveData) return;
|
|
428
|
+
const url = normalizeUrl(link.href);
|
|
429
|
+
if (url === currentUrl) return;
|
|
430
|
+
const regions = getVisitOptions(link).regions ?? config.regions;
|
|
431
|
+
prefetch(url, regions);
|
|
432
|
+
};
|
|
433
|
+
const findLink = (e) => {
|
|
434
|
+
const link = e.target?.closest?.("a");
|
|
435
|
+
if (!link || !getPrefetchStrategy(link) || !isInterceptable(link)) return;
|
|
436
|
+
return link;
|
|
437
|
+
};
|
|
438
|
+
const clearHover = () => {
|
|
439
|
+
clearTimeout(hoverTimeout);
|
|
440
|
+
hoverLink = void 0;
|
|
441
|
+
};
|
|
442
|
+
document.addEventListener("mouseover", (e) => {
|
|
443
|
+
const link = findLink(e);
|
|
444
|
+
if (!link || link === hoverLink || getPrefetchStrategy(link) !== "hover")
|
|
445
|
+
return;
|
|
446
|
+
clearHover();
|
|
447
|
+
hoverLink = link;
|
|
448
|
+
hoverTimeout = window.setTimeout(() => run(link), HOVER_DELAY);
|
|
449
|
+
});
|
|
450
|
+
document.addEventListener("mouseout", (e) => {
|
|
451
|
+
const to = e.relatedTarget;
|
|
452
|
+
if (hoverLink && !(to && hoverLink.contains(to))) clearHover();
|
|
453
|
+
});
|
|
454
|
+
document.addEventListener("focusin", (e) => {
|
|
455
|
+
const link = findLink(e);
|
|
456
|
+
if (link && getPrefetchStrategy(link) === "hover") run(link);
|
|
457
|
+
});
|
|
458
|
+
document.addEventListener(
|
|
459
|
+
"touchstart",
|
|
460
|
+
(e) => {
|
|
461
|
+
const link = findLink(e);
|
|
462
|
+
if (link) run(link);
|
|
463
|
+
},
|
|
464
|
+
{ passive: true }
|
|
465
|
+
);
|
|
466
|
+
document.addEventListener("mousedown", (e) => {
|
|
467
|
+
if (e.button !== 0) return;
|
|
468
|
+
const link = findLink(e);
|
|
469
|
+
if (link) run(link);
|
|
470
|
+
});
|
|
471
|
+
};
|
|
362
472
|
var interceptForms = () => {
|
|
363
473
|
document.addEventListener("submit", (e) => {
|
|
364
474
|
const form = e.target;
|
|
@@ -388,10 +498,12 @@ var interceptForms = () => {
|
|
|
388
498
|
var interceptHistory = () => {
|
|
389
499
|
window.addEventListener("popstate", (event) => {
|
|
390
500
|
if (normalizeUrl(window.location.href) === normalizeUrl(currentUrl)) return;
|
|
501
|
+
const isBack = (event.state?.index ?? 0) < (currentState?.index ?? 0);
|
|
502
|
+
const regions = isBack ? currentState?.regions : event.state?.regions;
|
|
391
503
|
visit(window.location.href, {
|
|
392
504
|
history: "none",
|
|
393
505
|
isBackForward: true,
|
|
394
|
-
regions:
|
|
506
|
+
regions: regions ?? config.regions,
|
|
395
507
|
state: event.state
|
|
396
508
|
});
|
|
397
509
|
});
|
|
@@ -429,6 +541,7 @@ var start = (options = {}) => {
|
|
|
429
541
|
interceptLinks();
|
|
430
542
|
interceptForms();
|
|
431
543
|
interceptHistory();
|
|
544
|
+
interceptPrefetch();
|
|
432
545
|
if (import.meta.env.DEV) {
|
|
433
546
|
document.addEventListener("ajax:before-visit", () => {
|
|
434
547
|
document.head.querySelectorAll("style[data-vite-dev-id]").forEach((el) => el.setAttribute("im-preserve", "true"));
|