@very-simple/ajax 1.0.0-beta.1 → 1.0.0-beta.11
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 +28 -4
- package/dist/index.d.ts +41 -11
- package/dist/index.js +238 -70
- 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,18 +63,28 @@ 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
|
-
|
|
70
|
+
// `'view'` wraps the swap in the View Transitions API (if supported), `'css'`
|
|
71
|
+
// keeps the old and new region in the DOM and sets `data-ajax-leaving` /
|
|
72
|
+
// `data-ajax-entering` for CSS transitions (only for the `replace` merge).
|
|
73
|
+
transitions: false,
|
|
71
74
|
// Delay (ms) before showing a loading indicator on slow visits.
|
|
72
75
|
loadingDelay: 500,
|
|
73
76
|
// Delay (ms) before hiding the loading indicator again.
|
|
74
77
|
progressHideDelay: 500,
|
|
75
78
|
// Prefix used for the `data-ajax-*` attributes.
|
|
76
79
|
prefix: 'data-ajax-',
|
|
80
|
+
// Default selectors to swap instead of the whole `<body>`, used for visits
|
|
81
|
+
// that don't specify their own `regions` (e.g. via `data-ajax-regions`).
|
|
82
|
+
regions: [],
|
|
83
|
+
// Default prefetch strategy for links: false, 'hover' (hover/focus) or
|
|
84
|
+
// 'mousedown' (press). Touch always prefetches on touchstart.
|
|
85
|
+
prefetch: false,
|
|
86
|
+
// How long (ms) a prefetched response stays usable.
|
|
87
|
+
prefetchTtl: 30000,
|
|
77
88
|
// Custom scroll handling, e.g. restoring position on back/forward visits.
|
|
78
89
|
scrollBehavior: ({ isBackForward, savedPosition }) =>
|
|
79
90
|
isBackForward ? savedPosition : { top: 0 },
|
|
@@ -84,6 +95,19 @@ start({
|
|
|
84
95
|
})
|
|
85
96
|
```
|
|
86
97
|
|
|
98
|
+
`head`, `executeScripts` and `transitions` also accept a callback
|
|
99
|
+
receiving `{ from, to, isBackForward }`. Combined with `data-ajax-state` this
|
|
100
|
+
allows deciding per link, e.g. to only add new `<head>` elements (scripts,
|
|
101
|
+
styles) without removing existing ones when loading a partial:
|
|
102
|
+
|
|
103
|
+
```html
|
|
104
|
+
<a href="/partial" data-ajax-state='{"isPartial": true}'>Partial</a>
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
start({ head: ({ to }) => (to.state?.isPartial ? 'append' : 'merge') })
|
|
109
|
+
```
|
|
110
|
+
|
|
87
111
|
### `visit(url, options?)`
|
|
88
112
|
|
|
89
113
|
A lower-level API for triggering a visit programmatically, e.g. in
|
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,17 +18,26 @@ 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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
/**
|
|
30
|
-
|
|
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>;
|
|
32
|
+
/** Re-execute `<script>` tags found in the swapped content. A callback decides per visit. */
|
|
33
|
+
executeScripts?: Resolvable<boolean>;
|
|
34
|
+
/**
|
|
35
|
+
* `'view'` wraps the swap in the View Transitions API (if supported), `'css'`
|
|
36
|
+
* keeps the old and new region in the DOM and sets `data-ajax-leaving` /
|
|
37
|
+
* `data-ajax-entering` so CSS can animate them (only for `replace`). A
|
|
38
|
+
* callback decides per visit.
|
|
39
|
+
*/
|
|
40
|
+
transitions?: Resolvable<false | 'view' | 'css'>;
|
|
31
41
|
/** Custom scroll handling, e.g. restoring position on back/forward visits. */
|
|
32
42
|
scrollBehavior?: ScrollBehavior;
|
|
33
43
|
/** Delay (ms) before showing a loading indicator on slow visits. */
|
|
@@ -36,11 +46,26 @@ interface Config {
|
|
|
36
46
|
progressHideDelay?: number;
|
|
37
47
|
/** Prefix used for the `data-ajax-*` attributes. */
|
|
38
48
|
prefix?: string;
|
|
49
|
+
/** Default regions used for visits that don't specify their own. */
|
|
50
|
+
regions?: string[];
|
|
51
|
+
/**
|
|
52
|
+
* Default prefetch strategy for links: `'hover'` on hover/focus, `'mousedown'`
|
|
53
|
+
* on press. Links can override it with `data-ajax-prefetch`.
|
|
54
|
+
*/
|
|
55
|
+
prefetch?: false | 'hover' | 'mousedown';
|
|
56
|
+
/** How long (ms) a prefetched response stays usable. */
|
|
57
|
+
prefetchTtl?: number;
|
|
39
58
|
/** Called after an element is added to the DOM by a merge. */
|
|
40
59
|
mount?: (el: Element) => void;
|
|
41
60
|
/** Called before an element is removed from the DOM by a merge. */
|
|
42
61
|
unmount?: (el: Element) => void;
|
|
43
62
|
}
|
|
63
|
+
/** A value, or a callback resolving it right before the swap. */
|
|
64
|
+
type Resolvable<T> = T | ((info: {
|
|
65
|
+
from: PageState;
|
|
66
|
+
to: PageState;
|
|
67
|
+
isBackForward: boolean;
|
|
68
|
+
}) => T);
|
|
44
69
|
type ScrollPosition = {
|
|
45
70
|
top: number;
|
|
46
71
|
};
|
|
@@ -72,6 +97,11 @@ type EventMap = {
|
|
|
72
97
|
'before-visit': Payload;
|
|
73
98
|
'before-swap': Payload;
|
|
74
99
|
'after-swap': Payload;
|
|
100
|
+
'progress-start': Record<string, never>;
|
|
101
|
+
progress: {
|
|
102
|
+
progress: number;
|
|
103
|
+
};
|
|
104
|
+
'progress-end': Record<string, never>;
|
|
75
105
|
};
|
|
76
106
|
type DomEventMap = {
|
|
77
107
|
[K in keyof EventMap as `ajax:${K & string}`]: CustomEvent<EventMap[K]>;
|
|
@@ -88,6 +118,6 @@ declare const scrollPositions: Map<string, ScrollPosition>;
|
|
|
88
118
|
* Load a new page, merge the regions/bodies and add a new history entry
|
|
89
119
|
* according to the history mode.
|
|
90
120
|
*/
|
|
91
|
-
declare const visit: (url: string, { history: historyMode, isBackForward, autoFocus, request, state, regions,
|
|
121
|
+
declare const visit: (url: string, { history: historyMode, isBackForward, autoFocus, request, state, regions, head, merge: mergeStrategy, executeScripts: executeScriptsOption, transitions, loadingDelay, progressHideDelay, }?: VisitOptions) => Promise<void>;
|
|
92
122
|
|
|
93
|
-
export { type AjaxState, type Config, type EventMap, type LoadingOptions, type MergeStrategy, type PageState, type ScrollBehavior, type ScrollPosition, type StartOptions, type VisitOptions, scrollPositions, start, visit };
|
|
123
|
+
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,78 +1,156 @@
|
|
|
1
1
|
// src/config.ts
|
|
2
2
|
var config = {
|
|
3
|
-
|
|
3
|
+
head: "merge",
|
|
4
4
|
merge: "replace",
|
|
5
5
|
executeScripts: true,
|
|
6
|
-
|
|
6
|
+
transitions: false,
|
|
7
7
|
loadingDelay: 500,
|
|
8
8
|
progressHideDelay: 500,
|
|
9
|
-
prefix: "data-ajax-"
|
|
9
|
+
prefix: "data-ajax-",
|
|
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
|
+
Accept: "text/html",
|
|
27
|
+
"X-Very-Simple-Ajax": "true",
|
|
28
|
+
"X-Very-Simple-Ajax-Regions": regions.join(" ")
|
|
29
|
+
}
|
|
30
|
+
}).then(async (response) => {
|
|
31
|
+
if (!response.ok) return void 0;
|
|
32
|
+
return { html: await response.text(), response };
|
|
33
|
+
}).catch(() => void 0).then((result) => {
|
|
34
|
+
if (!result) store.delete(key);
|
|
35
|
+
return result;
|
|
36
|
+
});
|
|
37
|
+
store.set(key, { time: Date.now(), promise });
|
|
38
|
+
};
|
|
39
|
+
var takePrefetched = (url, regions) => {
|
|
40
|
+
const key = keyOf(url, regions);
|
|
41
|
+
const entry = store.get(key);
|
|
42
|
+
store.delete(key);
|
|
43
|
+
return entry && isFresh(entry.time) ? entry.promise : void 0;
|
|
10
44
|
};
|
|
11
45
|
|
|
12
46
|
// src/load.ts
|
|
13
47
|
var parser = new DOMParser();
|
|
14
48
|
var currentLoadController;
|
|
49
|
+
var progressDelayTimeout;
|
|
15
50
|
var progress = 0;
|
|
16
51
|
var trickleInterval;
|
|
52
|
+
var emit = (type, payload) => document.dispatchEvent(new CustomEvent(`ajax:${type}`, { detail: payload }));
|
|
17
53
|
var cache = /* @__PURE__ */ new Map();
|
|
18
54
|
var load = async (url, regions, options) => {
|
|
19
|
-
let
|
|
55
|
+
let aborted = false;
|
|
20
56
|
try {
|
|
21
57
|
currentLoadController?.abort();
|
|
58
|
+
clearTimeout(progressDelayTimeout);
|
|
22
59
|
currentLoadController = new AbortController();
|
|
23
60
|
setProgress(0);
|
|
24
61
|
progressDelayTimeout = window.setTimeout(() => {
|
|
25
|
-
|
|
26
|
-
toggleProgress(true);
|
|
62
|
+
emit("progress-start", {});
|
|
27
63
|
startTrickle();
|
|
28
64
|
}, options.loadingDelay);
|
|
29
|
-
const
|
|
65
|
+
const prefetched = await takePrefetched(url, regions);
|
|
66
|
+
const response = prefetched?.response ?? await fetch(url, {
|
|
30
67
|
...options.request,
|
|
31
68
|
headers: {
|
|
69
|
+
Accept: "text/html",
|
|
32
70
|
...options.request?.headers,
|
|
33
71
|
"X-Very-Simple-Ajax": "true",
|
|
34
72
|
"X-Very-Simple-Ajax-Regions": regions.join(" ")
|
|
35
73
|
},
|
|
36
74
|
signal: currentLoadController.signal
|
|
37
75
|
});
|
|
38
|
-
const html = await response.text();
|
|
76
|
+
const html = prefetched?.html ?? await response.text();
|
|
39
77
|
if (response.ok) cache.set(url, html);
|
|
40
78
|
const document2 = parseHtml(html);
|
|
41
79
|
return { document: document2, response };
|
|
42
80
|
} catch (e) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
81
|
+
aborted = e instanceof DOMException && e.name === "AbortError";
|
|
82
|
+
if (!aborted) {
|
|
83
|
+
setProgress(0);
|
|
46
84
|
window.location.reload();
|
|
47
85
|
}
|
|
48
86
|
} finally {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
87
|
+
if (!aborted) {
|
|
88
|
+
clearTimeout(progressDelayTimeout);
|
|
89
|
+
stopTrickle();
|
|
90
|
+
setProgress(1);
|
|
91
|
+
setTimeout(() => emit("progress-end", {}), options.progressHideDelay);
|
|
92
|
+
}
|
|
54
93
|
}
|
|
55
94
|
};
|
|
56
95
|
var parseHtml = (html) => parser.parseFromString(html, "text/html");
|
|
57
96
|
var trickle = () => {
|
|
58
|
-
|
|
59
|
-
|
|
97
|
+
setProgress(progress + Math.random() * 0.1 * (1 - progress));
|
|
98
|
+
};
|
|
99
|
+
var startTrickle = () => {
|
|
100
|
+
stopTrickle();
|
|
101
|
+
trickleInterval = window.setInterval(trickle, 300);
|
|
102
|
+
};
|
|
103
|
+
var stopTrickle = () => {
|
|
104
|
+
clearInterval(trickleInterval);
|
|
105
|
+
trickleInterval = void 0;
|
|
60
106
|
};
|
|
61
|
-
var startTrickle = () => trickleInterval = window.setInterval(trickle, 300);
|
|
62
|
-
var stopTrickle = () => clearInterval(trickleInterval);
|
|
63
107
|
var setProgress = (value) => {
|
|
64
108
|
progress = value;
|
|
65
|
-
|
|
66
|
-
"--ajax-progress",
|
|
67
|
-
`${Math.round(value * 1e4) / 100}%`
|
|
68
|
-
);
|
|
109
|
+
emit("progress", { progress: value });
|
|
69
110
|
};
|
|
70
|
-
var toggleProgress = (state) => document.documentElement.toggleAttribute(`${config.prefix}progress`, state);
|
|
71
|
-
var toggleLoading = (state) => document.documentElement.toggleAttribute(`${config.prefix}loading`, state);
|
|
72
111
|
|
|
73
112
|
// src/merge.ts
|
|
74
113
|
import { Idiomorph } from "idiomorph/dist/idiomorph.esm.js";
|
|
75
|
-
var merge = (region, newRegion, strategy) => {
|
|
114
|
+
var merge = async (region, newRegion, strategy, signal, useCssTransition = false) => {
|
|
115
|
+
if (useCssTransition && strategy === "replace") {
|
|
116
|
+
return replaceWithTransition(region, newRegion, signal);
|
|
117
|
+
}
|
|
118
|
+
return mergeBuiltIn(region, newRegion, strategy);
|
|
119
|
+
};
|
|
120
|
+
var replaceWithTransition = async (region, newRegion, signal) => {
|
|
121
|
+
const { prefix, mount, unmount } = config;
|
|
122
|
+
const escapedPrefix = CSS.escape(prefix);
|
|
123
|
+
newRegion.querySelectorAll(`[${escapedPrefix}permanent][id]`).forEach((el) => {
|
|
124
|
+
const original = region.querySelector(`#${CSS.escape(el.id)}`);
|
|
125
|
+
if (original) el.replaceWith(original);
|
|
126
|
+
});
|
|
127
|
+
newRegion.setAttribute(`${prefix}entering`, "");
|
|
128
|
+
region.before(newRegion);
|
|
129
|
+
mount?.(newRegion);
|
|
130
|
+
newRegion.getBoundingClientRect();
|
|
131
|
+
newRegion.removeAttribute(`${prefix}entering`);
|
|
132
|
+
region.setAttribute(`${prefix}leaving`, "");
|
|
133
|
+
region.inert = true;
|
|
134
|
+
const animations = [region, newRegion].flatMap(
|
|
135
|
+
(el) => el.getAnimations({ subtree: true })
|
|
136
|
+
);
|
|
137
|
+
const finish = () => animations.forEach((a) => a.finish());
|
|
138
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
139
|
+
await Promise.allSettled(animations.map((a) => a.finished));
|
|
140
|
+
signal.removeEventListener("abort", finish);
|
|
141
|
+
unmount?.(region);
|
|
142
|
+
region.remove();
|
|
143
|
+
return { autoFocusEl: findAutoFocus(newRegion) };
|
|
144
|
+
};
|
|
145
|
+
var findAutoFocus = (newRegion) => {
|
|
146
|
+
const { prefix } = config;
|
|
147
|
+
const escapedPrefix = CSS.escape(prefix);
|
|
148
|
+
if (newRegion.hasAttribute(`${prefix}autofocus`) || newRegion.autofocus) {
|
|
149
|
+
return newRegion;
|
|
150
|
+
}
|
|
151
|
+
return newRegion.querySelector(`[${escapedPrefix}autofocus]`) ?? newRegion.querySelector("[autofocus]") ?? void 0;
|
|
152
|
+
};
|
|
153
|
+
var mergeBuiltIn = (region, newRegion, strategy) => {
|
|
76
154
|
let autoFocusEl;
|
|
77
155
|
const { prefix, mount, unmount } = config;
|
|
78
156
|
const escapedPrefix = CSS.escape(prefix);
|
|
@@ -81,7 +159,7 @@ var merge = (region, newRegion, strategy) => {
|
|
|
81
159
|
Array.from(
|
|
82
160
|
newRegion.querySelectorAll(`[${escapedPrefix}permanent][id]`)
|
|
83
161
|
).forEach((el) => {
|
|
84
|
-
const original = region.querySelector(`#${el.id}`);
|
|
162
|
+
const original = region.querySelector(`#${CSS.escape(el.id)}`);
|
|
85
163
|
if (original) {
|
|
86
164
|
permanents.set(el.id, original);
|
|
87
165
|
original.remove();
|
|
@@ -90,13 +168,12 @@ var merge = (region, newRegion, strategy) => {
|
|
|
90
168
|
unmount?.(region);
|
|
91
169
|
if (strategy === "replace") region.replaceWith(newRegion);
|
|
92
170
|
else region.replaceChildren(...Array.from(newRegion.children));
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
).forEach((el) => {
|
|
171
|
+
const target = strategy === "replace" ? newRegion : region;
|
|
172
|
+
target.querySelectorAll(`[${escapedPrefix}permanent][id]`).forEach((el) => {
|
|
96
173
|
const original = permanents.get(el.id);
|
|
97
174
|
if (original) el.replaceWith(original);
|
|
98
175
|
});
|
|
99
|
-
mount?.(
|
|
176
|
+
mount?.(target);
|
|
100
177
|
} else if (strategy === "before") {
|
|
101
178
|
region.insertAdjacentElement("beforebegin", newRegion);
|
|
102
179
|
mount?.(newRegion);
|
|
@@ -113,16 +190,13 @@ var merge = (region, newRegion, strategy) => {
|
|
|
113
190
|
const result = morph(region, newRegion);
|
|
114
191
|
autoFocusEl = result.autoFocusEl;
|
|
115
192
|
}
|
|
116
|
-
if (
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
autoFocusEl ??= newRegion.querySelector(`[${escapedPrefix}autofocus]`) ?? newRegion.querySelector("[autofocus]") ?? void 0;
|
|
120
|
-
return { autoFocusEl };
|
|
193
|
+
if (strategy === "morph") return { autoFocusEl };
|
|
194
|
+
return { autoFocusEl: findAutoFocus(newRegion) };
|
|
121
195
|
};
|
|
122
196
|
var morph = (container, newContainer) => {
|
|
123
197
|
const { prefix, mount, unmount } = config;
|
|
124
198
|
const manualReplacements = [];
|
|
125
|
-
|
|
199
|
+
const autoFocusEls = [];
|
|
126
200
|
let currentNodeKeepAttributes = [];
|
|
127
201
|
Idiomorph.morph(container, newContainer, {
|
|
128
202
|
callbacks: {
|
|
@@ -137,6 +211,7 @@ var morph = (container, newContainer) => {
|
|
|
137
211
|
const isSameComponent = oldIsComponent && newIsComponent && oldNode.getAttribute(`${prefix}component`) === newNode.getAttribute(`${prefix}component`);
|
|
138
212
|
if (!oldIsComponent || isSameComponent) return true;
|
|
139
213
|
manualReplacements.push([oldNode, newNode]);
|
|
214
|
+
return false;
|
|
140
215
|
},
|
|
141
216
|
beforeAttributeUpdated: (name) => {
|
|
142
217
|
return !currentNodeKeepAttributes.includes(name);
|
|
@@ -152,9 +227,9 @@ var morph = (container, newContainer) => {
|
|
|
152
227
|
mount?.(node);
|
|
153
228
|
}
|
|
154
229
|
},
|
|
155
|
-
afterNodeMorphed(
|
|
156
|
-
if (
|
|
157
|
-
autoFocusEls.push(
|
|
230
|
+
afterNodeMorphed(oldNode) {
|
|
231
|
+
if (oldNode instanceof HTMLElement && (oldNode.autofocus || oldNode.hasAttribute(`${prefix}autofocus`))) {
|
|
232
|
+
autoFocusEls.push(oldNode);
|
|
158
233
|
}
|
|
159
234
|
}
|
|
160
235
|
}
|
|
@@ -204,7 +279,7 @@ var normalizeUrl = (url) => {
|
|
|
204
279
|
return url;
|
|
205
280
|
}
|
|
206
281
|
};
|
|
207
|
-
var
|
|
282
|
+
var emit2 = (type, payload) => {
|
|
208
283
|
const waiting = [];
|
|
209
284
|
const waitUntil = (promise) => waiting.push(Promise.resolve(promise));
|
|
210
285
|
payload = { ...payload, waitUntil };
|
|
@@ -212,7 +287,7 @@ var emit = (type, payload) => {
|
|
|
212
287
|
document.dispatchEvent(event);
|
|
213
288
|
if (waiting.length) return Promise.allSettled(waiting);
|
|
214
289
|
};
|
|
215
|
-
var currentUrl = window.location.pathname;
|
|
290
|
+
var currentUrl = window.location.pathname + window.location.search;
|
|
216
291
|
var currentState = history.state;
|
|
217
292
|
var currentVisitController;
|
|
218
293
|
var scrollPositions = /* @__PURE__ */ new Map();
|
|
@@ -222,23 +297,27 @@ var visit = async (url, {
|
|
|
222
297
|
autoFocus = true,
|
|
223
298
|
request,
|
|
224
299
|
state,
|
|
225
|
-
regions =
|
|
226
|
-
|
|
300
|
+
regions = config.regions,
|
|
301
|
+
head = config.head,
|
|
227
302
|
merge: mergeStrategy = config.merge,
|
|
228
|
-
executeScripts:
|
|
229
|
-
|
|
303
|
+
executeScripts: executeScriptsOption = config.executeScripts,
|
|
304
|
+
transitions = config.transitions,
|
|
230
305
|
loadingDelay = config.loadingDelay,
|
|
231
306
|
progressHideDelay = config.progressHideDelay
|
|
232
307
|
} = {}) => {
|
|
233
308
|
url = normalizeUrl(url);
|
|
234
309
|
const fromUrl = currentUrl;
|
|
235
310
|
const fromState = currentState;
|
|
236
|
-
|
|
311
|
+
if (!(isBackForward && state)) {
|
|
312
|
+
const currentIndex = currentState?.index ?? 0;
|
|
313
|
+
const index = historyMode === "push" ? currentIndex + 1 : currentIndex;
|
|
314
|
+
state = { ...state, regions, index };
|
|
315
|
+
}
|
|
237
316
|
currentVisitController?.abort();
|
|
238
317
|
currentVisitController = new AbortController();
|
|
239
318
|
const { signal } = currentVisitController;
|
|
240
319
|
if (url === currentUrl) historyMode = "none";
|
|
241
|
-
await
|
|
320
|
+
await emit2("before-visit", {
|
|
242
321
|
from: { url: fromUrl, state: fromState, document },
|
|
243
322
|
to: { url, state },
|
|
244
323
|
signal,
|
|
@@ -248,7 +327,13 @@ var visit = async (url, {
|
|
|
248
327
|
let newDocument;
|
|
249
328
|
if (isBackForward) {
|
|
250
329
|
const html = cache.get(url);
|
|
251
|
-
if (html)
|
|
330
|
+
if (html) {
|
|
331
|
+
const cached = parseHtml(html);
|
|
332
|
+
const isComplete = regions.every(
|
|
333
|
+
(selector) => !document.querySelector(selector) || cached.querySelector(selector)
|
|
334
|
+
);
|
|
335
|
+
if (isComplete) newDocument = cached;
|
|
336
|
+
}
|
|
252
337
|
}
|
|
253
338
|
if (!newDocument) {
|
|
254
339
|
const options = { loadingDelay, progressHideDelay, request };
|
|
@@ -259,15 +344,28 @@ var visit = async (url, {
|
|
|
259
344
|
}
|
|
260
345
|
}
|
|
261
346
|
if (!newDocument) return;
|
|
347
|
+
if (signal.aborted) return;
|
|
262
348
|
currentUrl = url;
|
|
263
349
|
currentState = state;
|
|
264
350
|
if (historyMode === "replace") history.replaceState(state, "", url);
|
|
265
351
|
else if (historyMode === "push") history.pushState(state, "", url);
|
|
266
352
|
const from = { url: fromUrl, state: fromState, document };
|
|
267
353
|
const to = { url, state, document: newDocument };
|
|
268
|
-
await
|
|
354
|
+
await emit2("before-swap", { from, to, isBackForward, signal });
|
|
269
355
|
if (signal.aborted) return;
|
|
270
|
-
|
|
356
|
+
const resolve = (option) => typeof option === "function" ? option({ from, to, isBackForward }) : option;
|
|
357
|
+
const resolvedHead = resolve(head);
|
|
358
|
+
let headStyle = resolvedHead;
|
|
359
|
+
if (headStyle === true) headStyle = "merge";
|
|
360
|
+
else if (headStyle === false) headStyle = "none";
|
|
361
|
+
const updateHead = () => {
|
|
362
|
+
if (headStyle === "none") return;
|
|
363
|
+
Idiomorph2.morph(document.head, newDocument.head, {
|
|
364
|
+
head: { style: headStyle }
|
|
365
|
+
});
|
|
366
|
+
};
|
|
367
|
+
const shouldExecuteScripts = resolve(executeScriptsOption);
|
|
368
|
+
const transition = resolve(transitions);
|
|
271
369
|
const getMergeStrategy = (oldEl, newEl) => newEl.getAttribute(`${config.prefix}merge`) || oldEl.getAttribute(`${config.prefix}merge`) || mergeStrategy;
|
|
272
370
|
let autoFocusEl;
|
|
273
371
|
const hasMatchingRegions = regions?.some(
|
|
@@ -278,10 +376,17 @@ var visit = async (url, {
|
|
|
278
376
|
if (hasMatchingRegions) {
|
|
279
377
|
for (const id of regions) {
|
|
280
378
|
const region = document.querySelector(id);
|
|
281
|
-
const
|
|
282
|
-
if (!region || !
|
|
379
|
+
const parsedRegion = newDocument.querySelector(id);
|
|
380
|
+
if (!region || !parsedRegion) continue;
|
|
381
|
+
const newRegion = document.importNode(parsedRegion, true);
|
|
283
382
|
const strategy = getMergeStrategy(region, newRegion);
|
|
284
|
-
const result = merge(
|
|
383
|
+
const result = await merge(
|
|
384
|
+
region,
|
|
385
|
+
newRegion,
|
|
386
|
+
strategy,
|
|
387
|
+
signal,
|
|
388
|
+
transition === "css"
|
|
389
|
+
);
|
|
285
390
|
autoFocusEl ??= result.autoFocusEl;
|
|
286
391
|
if (shouldExecuteScripts) {
|
|
287
392
|
const finalRegion = document.querySelector(id);
|
|
@@ -290,9 +395,9 @@ var visit = async (url, {
|
|
|
290
395
|
}
|
|
291
396
|
} else {
|
|
292
397
|
const region = document.body;
|
|
293
|
-
const newRegion = newDocument.body;
|
|
398
|
+
const newRegion = document.importNode(newDocument.body, true);
|
|
294
399
|
const strategy = getMergeStrategy(region, newRegion);
|
|
295
|
-
const result = merge(region, newRegion, strategy);
|
|
400
|
+
const result = await merge(region, newRegion, strategy, signal);
|
|
296
401
|
autoFocusEl = result.autoFocusEl;
|
|
297
402
|
if (shouldExecuteScripts) {
|
|
298
403
|
scriptPromises.push(executeScripts(document.body));
|
|
@@ -312,22 +417,22 @@ var visit = async (url, {
|
|
|
312
417
|
});
|
|
313
418
|
if (position) window.scrollTo(position);
|
|
314
419
|
};
|
|
315
|
-
if (
|
|
316
|
-
await config.render(newDocument);
|
|
317
|
-
} else if (viewTransitions && document.startViewTransition) {
|
|
420
|
+
if (transition === "view" && document.startViewTransition) {
|
|
318
421
|
await document.startViewTransition(async () => {
|
|
422
|
+
updateHead();
|
|
319
423
|
await mergeRegions();
|
|
320
|
-
await
|
|
424
|
+
await emit2("after-swap", { from, to, isBackForward, signal });
|
|
321
425
|
applyScrollBehavior();
|
|
322
|
-
}).
|
|
426
|
+
}).updateCallbackDone;
|
|
323
427
|
} else {
|
|
428
|
+
updateHead();
|
|
324
429
|
await mergeRegions();
|
|
325
|
-
await
|
|
430
|
+
await emit2("after-swap", { from, to, isBackForward, signal });
|
|
326
431
|
applyScrollBehavior();
|
|
327
432
|
}
|
|
328
433
|
if (signal.aborted) return;
|
|
329
434
|
if (autoFocus) autoFocusEl?.focus({ preventScroll: true });
|
|
330
|
-
|
|
435
|
+
emit2("load", { from, to, isBackForward });
|
|
331
436
|
};
|
|
332
437
|
|
|
333
438
|
// src/intercept.ts
|
|
@@ -336,15 +441,73 @@ var interceptLinks = () => {
|
|
|
336
441
|
if (e.defaultPrevented || isModifiedClick(e)) return;
|
|
337
442
|
const target = e.composedPath ? e.composedPath()[0] : e.target;
|
|
338
443
|
const link = target?.closest?.("a");
|
|
339
|
-
if (!link || !link
|
|
340
|
-
const isOptOut = link.hasAttribute(`${config.prefix}reload`);
|
|
341
|
-
const isExternalTarget = link.target && link.target !== "_self";
|
|
342
|
-
if (isOptOut || link.hasAttribute("download") || isExternalTarget || !isSameOrigin(link.href))
|
|
343
|
-
return;
|
|
444
|
+
if (!link || !isInterceptable(link)) return;
|
|
344
445
|
e.preventDefault();
|
|
345
446
|
visit(link.href, { history: "push", ...getVisitOptions(link) });
|
|
346
447
|
});
|
|
347
448
|
};
|
|
449
|
+
var isInterceptable = (link) => {
|
|
450
|
+
if (!link.href) return false;
|
|
451
|
+
const isExternalTarget = link.target && link.target !== "_self";
|
|
452
|
+
return !(link.hasAttribute(`${config.prefix}reload`) || link.hasAttribute("download") || isExternalTarget || !isSameOrigin(link.href));
|
|
453
|
+
};
|
|
454
|
+
var hoverDelay = 100;
|
|
455
|
+
var getPrefetchStrategy = (link) => {
|
|
456
|
+
const attr = link.getAttribute(`${config.prefix}prefetch`);
|
|
457
|
+
if (attr === null) return config.prefetch;
|
|
458
|
+
if (attr === "false") return false;
|
|
459
|
+
if (attr === "hover" || attr === "mousedown") return attr;
|
|
460
|
+
return config.prefetch || "hover";
|
|
461
|
+
};
|
|
462
|
+
var interceptPrefetch = () => {
|
|
463
|
+
let hoverTimeout;
|
|
464
|
+
let hoverLink;
|
|
465
|
+
const run = (link) => {
|
|
466
|
+
if (navigator.connection?.saveData) return;
|
|
467
|
+
const url = normalizeUrl(link.href);
|
|
468
|
+
if (url === currentUrl) return;
|
|
469
|
+
const regions = getVisitOptions(link).regions ?? config.regions;
|
|
470
|
+
prefetch(url, regions);
|
|
471
|
+
};
|
|
472
|
+
const findLink = (e) => {
|
|
473
|
+
const link = e.target?.closest?.("a");
|
|
474
|
+
if (!link || !getPrefetchStrategy(link) || !isInterceptable(link)) return;
|
|
475
|
+
return link;
|
|
476
|
+
};
|
|
477
|
+
const clearHover = () => {
|
|
478
|
+
clearTimeout(hoverTimeout);
|
|
479
|
+
hoverLink = void 0;
|
|
480
|
+
};
|
|
481
|
+
document.addEventListener("mouseover", (e) => {
|
|
482
|
+
const link = findLink(e);
|
|
483
|
+
if (!link || link === hoverLink || getPrefetchStrategy(link) !== "hover")
|
|
484
|
+
return;
|
|
485
|
+
clearHover();
|
|
486
|
+
hoverLink = link;
|
|
487
|
+
hoverTimeout = window.setTimeout(() => run(link), hoverDelay);
|
|
488
|
+
});
|
|
489
|
+
document.addEventListener("mouseout", (e) => {
|
|
490
|
+
const to = e.relatedTarget;
|
|
491
|
+
if (hoverLink && !(to && hoverLink.contains(to))) clearHover();
|
|
492
|
+
});
|
|
493
|
+
document.addEventListener("focusin", (e) => {
|
|
494
|
+
const link = findLink(e);
|
|
495
|
+
if (link && getPrefetchStrategy(link) === "hover") run(link);
|
|
496
|
+
});
|
|
497
|
+
document.addEventListener(
|
|
498
|
+
"touchstart",
|
|
499
|
+
(e) => {
|
|
500
|
+
const link = findLink(e);
|
|
501
|
+
if (link) run(link);
|
|
502
|
+
},
|
|
503
|
+
{ passive: true }
|
|
504
|
+
);
|
|
505
|
+
document.addEventListener("mousedown", (e) => {
|
|
506
|
+
if (e.button !== 0) return;
|
|
507
|
+
const link = findLink(e);
|
|
508
|
+
if (link) run(link);
|
|
509
|
+
});
|
|
510
|
+
};
|
|
348
511
|
var interceptForms = () => {
|
|
349
512
|
document.addEventListener("submit", (e) => {
|
|
350
513
|
const form = e.target;
|
|
@@ -374,10 +537,12 @@ var interceptForms = () => {
|
|
|
374
537
|
var interceptHistory = () => {
|
|
375
538
|
window.addEventListener("popstate", (event) => {
|
|
376
539
|
if (normalizeUrl(window.location.href) === normalizeUrl(currentUrl)) return;
|
|
540
|
+
const isBack = (event.state?.index ?? 0) < (currentState?.index ?? 0);
|
|
541
|
+
const regions = isBack ? currentState?.regions : event.state?.regions;
|
|
377
542
|
visit(window.location.href, {
|
|
378
543
|
history: "none",
|
|
379
544
|
isBackForward: true,
|
|
380
|
-
regions:
|
|
545
|
+
regions: regions ?? config.regions,
|
|
381
546
|
state: event.state
|
|
382
547
|
});
|
|
383
548
|
});
|
|
@@ -409,10 +574,13 @@ var getVisitOptions = (el) => {
|
|
|
409
574
|
// src/start.ts
|
|
410
575
|
var start = (options = {}) => {
|
|
411
576
|
Object.assign(config, options);
|
|
577
|
+
const url = normalizeUrl(window.location.href);
|
|
578
|
+
if (!cache.has(url)) cache.set(url, document.documentElement.outerHTML);
|
|
412
579
|
if (config.scrollBehavior) history.scrollRestoration = "manual";
|
|
413
580
|
interceptLinks();
|
|
414
581
|
interceptForms();
|
|
415
582
|
interceptHistory();
|
|
583
|
+
interceptPrefetch();
|
|
416
584
|
if (import.meta.env.DEV) {
|
|
417
585
|
document.addEventListener("ajax:before-visit", () => {
|
|
418
586
|
document.head.querySelectorAll("style[data-vite-dev-id]").forEach((el) => el.setAttribute("im-preserve", "true"));
|