@very-simple/ajax 1.0.0-beta.1 → 1.0.0-beta.10
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 +236 -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,32 +1,68 @@
|
|
|
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
|
+
"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;
|
|
10
43
|
};
|
|
11
44
|
|
|
12
45
|
// src/load.ts
|
|
13
46
|
var parser = new DOMParser();
|
|
14
47
|
var currentLoadController;
|
|
48
|
+
var progressDelayTimeout;
|
|
15
49
|
var progress = 0;
|
|
16
50
|
var trickleInterval;
|
|
51
|
+
var emit = (type, payload) => document.dispatchEvent(new CustomEvent(`ajax:${type}`, { detail: payload }));
|
|
17
52
|
var cache = /* @__PURE__ */ new Map();
|
|
18
53
|
var load = async (url, regions, options) => {
|
|
19
|
-
let
|
|
54
|
+
let aborted = false;
|
|
20
55
|
try {
|
|
21
56
|
currentLoadController?.abort();
|
|
57
|
+
clearTimeout(progressDelayTimeout);
|
|
22
58
|
currentLoadController = new AbortController();
|
|
23
59
|
setProgress(0);
|
|
24
60
|
progressDelayTimeout = window.setTimeout(() => {
|
|
25
|
-
|
|
26
|
-
toggleProgress(true);
|
|
61
|
+
emit("progress-start", {});
|
|
27
62
|
startTrickle();
|
|
28
63
|
}, options.loadingDelay);
|
|
29
|
-
const
|
|
64
|
+
const prefetched = await takePrefetched(url, regions);
|
|
65
|
+
const response = prefetched?.response ?? await fetch(url, {
|
|
30
66
|
...options.request,
|
|
31
67
|
headers: {
|
|
32
68
|
...options.request?.headers,
|
|
@@ -35,44 +71,84 @@ var load = async (url, regions, options) => {
|
|
|
35
71
|
},
|
|
36
72
|
signal: currentLoadController.signal
|
|
37
73
|
});
|
|
38
|
-
const html = await response.text();
|
|
74
|
+
const html = prefetched?.html ?? await response.text();
|
|
39
75
|
if (response.ok) cache.set(url, html);
|
|
40
76
|
const document2 = parseHtml(html);
|
|
41
77
|
return { document: document2, response };
|
|
42
78
|
} catch (e) {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
79
|
+
aborted = e instanceof DOMException && e.name === "AbortError";
|
|
80
|
+
if (!aborted) {
|
|
81
|
+
setProgress(0);
|
|
46
82
|
window.location.reload();
|
|
47
83
|
}
|
|
48
84
|
} finally {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
85
|
+
if (!aborted) {
|
|
86
|
+
clearTimeout(progressDelayTimeout);
|
|
87
|
+
stopTrickle();
|
|
88
|
+
setProgress(1);
|
|
89
|
+
setTimeout(() => emit("progress-end", {}), options.progressHideDelay);
|
|
90
|
+
}
|
|
54
91
|
}
|
|
55
92
|
};
|
|
56
93
|
var parseHtml = (html) => parser.parseFromString(html, "text/html");
|
|
57
94
|
var trickle = () => {
|
|
58
|
-
|
|
59
|
-
|
|
95
|
+
setProgress(progress + Math.random() * 0.1 * (1 - progress));
|
|
96
|
+
};
|
|
97
|
+
var startTrickle = () => {
|
|
98
|
+
stopTrickle();
|
|
99
|
+
trickleInterval = window.setInterval(trickle, 300);
|
|
100
|
+
};
|
|
101
|
+
var stopTrickle = () => {
|
|
102
|
+
clearInterval(trickleInterval);
|
|
103
|
+
trickleInterval = void 0;
|
|
60
104
|
};
|
|
61
|
-
var startTrickle = () => trickleInterval = window.setInterval(trickle, 300);
|
|
62
|
-
var stopTrickle = () => clearInterval(trickleInterval);
|
|
63
105
|
var setProgress = (value) => {
|
|
64
106
|
progress = value;
|
|
65
|
-
|
|
66
|
-
"--ajax-progress",
|
|
67
|
-
`${Math.round(value * 1e4) / 100}%`
|
|
68
|
-
);
|
|
107
|
+
emit("progress", { progress: value });
|
|
69
108
|
};
|
|
70
|
-
var toggleProgress = (state) => document.documentElement.toggleAttribute(`${config.prefix}progress`, state);
|
|
71
|
-
var toggleLoading = (state) => document.documentElement.toggleAttribute(`${config.prefix}loading`, state);
|
|
72
109
|
|
|
73
110
|
// src/merge.ts
|
|
74
111
|
import { Idiomorph } from "idiomorph/dist/idiomorph.esm.js";
|
|
75
|
-
var merge = (region, newRegion, strategy) => {
|
|
112
|
+
var merge = async (region, newRegion, strategy, signal, useCssTransition = false) => {
|
|
113
|
+
if (useCssTransition && strategy === "replace") {
|
|
114
|
+
return replaceWithTransition(region, newRegion, signal);
|
|
115
|
+
}
|
|
116
|
+
return mergeBuiltIn(region, newRegion, strategy);
|
|
117
|
+
};
|
|
118
|
+
var replaceWithTransition = async (region, newRegion, signal) => {
|
|
119
|
+
const { prefix, mount, unmount } = config;
|
|
120
|
+
const escapedPrefix = CSS.escape(prefix);
|
|
121
|
+
newRegion.querySelectorAll(`[${escapedPrefix}permanent][id]`).forEach((el) => {
|
|
122
|
+
const original = region.querySelector(`#${CSS.escape(el.id)}`);
|
|
123
|
+
if (original) el.replaceWith(original);
|
|
124
|
+
});
|
|
125
|
+
newRegion.setAttribute(`${prefix}entering`, "");
|
|
126
|
+
region.before(newRegion);
|
|
127
|
+
mount?.(newRegion);
|
|
128
|
+
newRegion.getBoundingClientRect();
|
|
129
|
+
newRegion.removeAttribute(`${prefix}entering`);
|
|
130
|
+
region.setAttribute(`${prefix}leaving`, "");
|
|
131
|
+
region.inert = true;
|
|
132
|
+
const animations = [region, newRegion].flatMap(
|
|
133
|
+
(el) => el.getAnimations({ subtree: true })
|
|
134
|
+
);
|
|
135
|
+
const finish = () => animations.forEach((a) => a.finish());
|
|
136
|
+
signal.addEventListener("abort", finish, { once: true });
|
|
137
|
+
await Promise.allSettled(animations.map((a) => a.finished));
|
|
138
|
+
signal.removeEventListener("abort", finish);
|
|
139
|
+
unmount?.(region);
|
|
140
|
+
region.remove();
|
|
141
|
+
return { autoFocusEl: findAutoFocus(newRegion) };
|
|
142
|
+
};
|
|
143
|
+
var findAutoFocus = (newRegion) => {
|
|
144
|
+
const { prefix } = config;
|
|
145
|
+
const escapedPrefix = CSS.escape(prefix);
|
|
146
|
+
if (newRegion.hasAttribute(`${prefix}autofocus`) || newRegion.autofocus) {
|
|
147
|
+
return newRegion;
|
|
148
|
+
}
|
|
149
|
+
return newRegion.querySelector(`[${escapedPrefix}autofocus]`) ?? newRegion.querySelector("[autofocus]") ?? void 0;
|
|
150
|
+
};
|
|
151
|
+
var mergeBuiltIn = (region, newRegion, strategy) => {
|
|
76
152
|
let autoFocusEl;
|
|
77
153
|
const { prefix, mount, unmount } = config;
|
|
78
154
|
const escapedPrefix = CSS.escape(prefix);
|
|
@@ -81,7 +157,7 @@ var merge = (region, newRegion, strategy) => {
|
|
|
81
157
|
Array.from(
|
|
82
158
|
newRegion.querySelectorAll(`[${escapedPrefix}permanent][id]`)
|
|
83
159
|
).forEach((el) => {
|
|
84
|
-
const original = region.querySelector(`#${el.id}`);
|
|
160
|
+
const original = region.querySelector(`#${CSS.escape(el.id)}`);
|
|
85
161
|
if (original) {
|
|
86
162
|
permanents.set(el.id, original);
|
|
87
163
|
original.remove();
|
|
@@ -90,13 +166,12 @@ var merge = (region, newRegion, strategy) => {
|
|
|
90
166
|
unmount?.(region);
|
|
91
167
|
if (strategy === "replace") region.replaceWith(newRegion);
|
|
92
168
|
else region.replaceChildren(...Array.from(newRegion.children));
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
).forEach((el) => {
|
|
169
|
+
const target = strategy === "replace" ? newRegion : region;
|
|
170
|
+
target.querySelectorAll(`[${escapedPrefix}permanent][id]`).forEach((el) => {
|
|
96
171
|
const original = permanents.get(el.id);
|
|
97
172
|
if (original) el.replaceWith(original);
|
|
98
173
|
});
|
|
99
|
-
mount?.(
|
|
174
|
+
mount?.(target);
|
|
100
175
|
} else if (strategy === "before") {
|
|
101
176
|
region.insertAdjacentElement("beforebegin", newRegion);
|
|
102
177
|
mount?.(newRegion);
|
|
@@ -113,16 +188,13 @@ var merge = (region, newRegion, strategy) => {
|
|
|
113
188
|
const result = morph(region, newRegion);
|
|
114
189
|
autoFocusEl = result.autoFocusEl;
|
|
115
190
|
}
|
|
116
|
-
if (
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
autoFocusEl ??= newRegion.querySelector(`[${escapedPrefix}autofocus]`) ?? newRegion.querySelector("[autofocus]") ?? void 0;
|
|
120
|
-
return { autoFocusEl };
|
|
191
|
+
if (strategy === "morph") return { autoFocusEl };
|
|
192
|
+
return { autoFocusEl: findAutoFocus(newRegion) };
|
|
121
193
|
};
|
|
122
194
|
var morph = (container, newContainer) => {
|
|
123
195
|
const { prefix, mount, unmount } = config;
|
|
124
196
|
const manualReplacements = [];
|
|
125
|
-
|
|
197
|
+
const autoFocusEls = [];
|
|
126
198
|
let currentNodeKeepAttributes = [];
|
|
127
199
|
Idiomorph.morph(container, newContainer, {
|
|
128
200
|
callbacks: {
|
|
@@ -137,6 +209,7 @@ var morph = (container, newContainer) => {
|
|
|
137
209
|
const isSameComponent = oldIsComponent && newIsComponent && oldNode.getAttribute(`${prefix}component`) === newNode.getAttribute(`${prefix}component`);
|
|
138
210
|
if (!oldIsComponent || isSameComponent) return true;
|
|
139
211
|
manualReplacements.push([oldNode, newNode]);
|
|
212
|
+
return false;
|
|
140
213
|
},
|
|
141
214
|
beforeAttributeUpdated: (name) => {
|
|
142
215
|
return !currentNodeKeepAttributes.includes(name);
|
|
@@ -152,9 +225,9 @@ var morph = (container, newContainer) => {
|
|
|
152
225
|
mount?.(node);
|
|
153
226
|
}
|
|
154
227
|
},
|
|
155
|
-
afterNodeMorphed(
|
|
156
|
-
if (
|
|
157
|
-
autoFocusEls.push(
|
|
228
|
+
afterNodeMorphed(oldNode) {
|
|
229
|
+
if (oldNode instanceof HTMLElement && (oldNode.autofocus || oldNode.hasAttribute(`${prefix}autofocus`))) {
|
|
230
|
+
autoFocusEls.push(oldNode);
|
|
158
231
|
}
|
|
159
232
|
}
|
|
160
233
|
}
|
|
@@ -204,7 +277,7 @@ var normalizeUrl = (url) => {
|
|
|
204
277
|
return url;
|
|
205
278
|
}
|
|
206
279
|
};
|
|
207
|
-
var
|
|
280
|
+
var emit2 = (type, payload) => {
|
|
208
281
|
const waiting = [];
|
|
209
282
|
const waitUntil = (promise) => waiting.push(Promise.resolve(promise));
|
|
210
283
|
payload = { ...payload, waitUntil };
|
|
@@ -212,7 +285,7 @@ var emit = (type, payload) => {
|
|
|
212
285
|
document.dispatchEvent(event);
|
|
213
286
|
if (waiting.length) return Promise.allSettled(waiting);
|
|
214
287
|
};
|
|
215
|
-
var currentUrl = window.location.pathname;
|
|
288
|
+
var currentUrl = window.location.pathname + window.location.search;
|
|
216
289
|
var currentState = history.state;
|
|
217
290
|
var currentVisitController;
|
|
218
291
|
var scrollPositions = /* @__PURE__ */ new Map();
|
|
@@ -222,23 +295,27 @@ var visit = async (url, {
|
|
|
222
295
|
autoFocus = true,
|
|
223
296
|
request,
|
|
224
297
|
state,
|
|
225
|
-
regions =
|
|
226
|
-
|
|
298
|
+
regions = config.regions,
|
|
299
|
+
head = config.head,
|
|
227
300
|
merge: mergeStrategy = config.merge,
|
|
228
|
-
executeScripts:
|
|
229
|
-
|
|
301
|
+
executeScripts: executeScriptsOption = config.executeScripts,
|
|
302
|
+
transitions = config.transitions,
|
|
230
303
|
loadingDelay = config.loadingDelay,
|
|
231
304
|
progressHideDelay = config.progressHideDelay
|
|
232
305
|
} = {}) => {
|
|
233
306
|
url = normalizeUrl(url);
|
|
234
307
|
const fromUrl = currentUrl;
|
|
235
308
|
const fromState = currentState;
|
|
236
|
-
|
|
309
|
+
if (!(isBackForward && state)) {
|
|
310
|
+
const currentIndex = currentState?.index ?? 0;
|
|
311
|
+
const index = historyMode === "push" ? currentIndex + 1 : currentIndex;
|
|
312
|
+
state = { ...state, regions, index };
|
|
313
|
+
}
|
|
237
314
|
currentVisitController?.abort();
|
|
238
315
|
currentVisitController = new AbortController();
|
|
239
316
|
const { signal } = currentVisitController;
|
|
240
317
|
if (url === currentUrl) historyMode = "none";
|
|
241
|
-
await
|
|
318
|
+
await emit2("before-visit", {
|
|
242
319
|
from: { url: fromUrl, state: fromState, document },
|
|
243
320
|
to: { url, state },
|
|
244
321
|
signal,
|
|
@@ -248,7 +325,13 @@ var visit = async (url, {
|
|
|
248
325
|
let newDocument;
|
|
249
326
|
if (isBackForward) {
|
|
250
327
|
const html = cache.get(url);
|
|
251
|
-
if (html)
|
|
328
|
+
if (html) {
|
|
329
|
+
const cached = parseHtml(html);
|
|
330
|
+
const isComplete = regions.every(
|
|
331
|
+
(selector) => !document.querySelector(selector) || cached.querySelector(selector)
|
|
332
|
+
);
|
|
333
|
+
if (isComplete) newDocument = cached;
|
|
334
|
+
}
|
|
252
335
|
}
|
|
253
336
|
if (!newDocument) {
|
|
254
337
|
const options = { loadingDelay, progressHideDelay, request };
|
|
@@ -259,15 +342,28 @@ var visit = async (url, {
|
|
|
259
342
|
}
|
|
260
343
|
}
|
|
261
344
|
if (!newDocument) return;
|
|
345
|
+
if (signal.aborted) return;
|
|
262
346
|
currentUrl = url;
|
|
263
347
|
currentState = state;
|
|
264
348
|
if (historyMode === "replace") history.replaceState(state, "", url);
|
|
265
349
|
else if (historyMode === "push") history.pushState(state, "", url);
|
|
266
350
|
const from = { url: fromUrl, state: fromState, document };
|
|
267
351
|
const to = { url, state, document: newDocument };
|
|
268
|
-
await
|
|
352
|
+
await emit2("before-swap", { from, to, isBackForward, signal });
|
|
269
353
|
if (signal.aborted) return;
|
|
270
|
-
|
|
354
|
+
const resolve = (option) => typeof option === "function" ? option({ from, to, isBackForward }) : option;
|
|
355
|
+
const resolvedHead = resolve(head);
|
|
356
|
+
let headStyle = resolvedHead;
|
|
357
|
+
if (headStyle === true) headStyle = "merge";
|
|
358
|
+
else if (headStyle === false) headStyle = "none";
|
|
359
|
+
const updateHead = () => {
|
|
360
|
+
if (headStyle === "none") return;
|
|
361
|
+
Idiomorph2.morph(document.head, newDocument.head, {
|
|
362
|
+
head: { style: headStyle }
|
|
363
|
+
});
|
|
364
|
+
};
|
|
365
|
+
const shouldExecuteScripts = resolve(executeScriptsOption);
|
|
366
|
+
const transition = resolve(transitions);
|
|
271
367
|
const getMergeStrategy = (oldEl, newEl) => newEl.getAttribute(`${config.prefix}merge`) || oldEl.getAttribute(`${config.prefix}merge`) || mergeStrategy;
|
|
272
368
|
let autoFocusEl;
|
|
273
369
|
const hasMatchingRegions = regions?.some(
|
|
@@ -278,10 +374,17 @@ var visit = async (url, {
|
|
|
278
374
|
if (hasMatchingRegions) {
|
|
279
375
|
for (const id of regions) {
|
|
280
376
|
const region = document.querySelector(id);
|
|
281
|
-
const
|
|
282
|
-
if (!region || !
|
|
377
|
+
const parsedRegion = newDocument.querySelector(id);
|
|
378
|
+
if (!region || !parsedRegion) continue;
|
|
379
|
+
const newRegion = document.importNode(parsedRegion, true);
|
|
283
380
|
const strategy = getMergeStrategy(region, newRegion);
|
|
284
|
-
const result = merge(
|
|
381
|
+
const result = await merge(
|
|
382
|
+
region,
|
|
383
|
+
newRegion,
|
|
384
|
+
strategy,
|
|
385
|
+
signal,
|
|
386
|
+
transition === "css"
|
|
387
|
+
);
|
|
285
388
|
autoFocusEl ??= result.autoFocusEl;
|
|
286
389
|
if (shouldExecuteScripts) {
|
|
287
390
|
const finalRegion = document.querySelector(id);
|
|
@@ -290,9 +393,9 @@ var visit = async (url, {
|
|
|
290
393
|
}
|
|
291
394
|
} else {
|
|
292
395
|
const region = document.body;
|
|
293
|
-
const newRegion = newDocument.body;
|
|
396
|
+
const newRegion = document.importNode(newDocument.body, true);
|
|
294
397
|
const strategy = getMergeStrategy(region, newRegion);
|
|
295
|
-
const result = merge(region, newRegion, strategy);
|
|
398
|
+
const result = await merge(region, newRegion, strategy, signal);
|
|
296
399
|
autoFocusEl = result.autoFocusEl;
|
|
297
400
|
if (shouldExecuteScripts) {
|
|
298
401
|
scriptPromises.push(executeScripts(document.body));
|
|
@@ -312,22 +415,22 @@ var visit = async (url, {
|
|
|
312
415
|
});
|
|
313
416
|
if (position) window.scrollTo(position);
|
|
314
417
|
};
|
|
315
|
-
if (
|
|
316
|
-
await config.render(newDocument);
|
|
317
|
-
} else if (viewTransitions && document.startViewTransition) {
|
|
418
|
+
if (transition === "view" && document.startViewTransition) {
|
|
318
419
|
await document.startViewTransition(async () => {
|
|
420
|
+
updateHead();
|
|
319
421
|
await mergeRegions();
|
|
320
|
-
await
|
|
422
|
+
await emit2("after-swap", { from, to, isBackForward, signal });
|
|
321
423
|
applyScrollBehavior();
|
|
322
|
-
}).
|
|
424
|
+
}).updateCallbackDone;
|
|
323
425
|
} else {
|
|
426
|
+
updateHead();
|
|
324
427
|
await mergeRegions();
|
|
325
|
-
await
|
|
428
|
+
await emit2("after-swap", { from, to, isBackForward, signal });
|
|
326
429
|
applyScrollBehavior();
|
|
327
430
|
}
|
|
328
431
|
if (signal.aborted) return;
|
|
329
432
|
if (autoFocus) autoFocusEl?.focus({ preventScroll: true });
|
|
330
|
-
|
|
433
|
+
emit2("load", { from, to, isBackForward });
|
|
331
434
|
};
|
|
332
435
|
|
|
333
436
|
// src/intercept.ts
|
|
@@ -336,15 +439,73 @@ var interceptLinks = () => {
|
|
|
336
439
|
if (e.defaultPrevented || isModifiedClick(e)) return;
|
|
337
440
|
const target = e.composedPath ? e.composedPath()[0] : e.target;
|
|
338
441
|
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;
|
|
442
|
+
if (!link || !isInterceptable(link)) return;
|
|
344
443
|
e.preventDefault();
|
|
345
444
|
visit(link.href, { history: "push", ...getVisitOptions(link) });
|
|
346
445
|
});
|
|
347
446
|
};
|
|
447
|
+
var isInterceptable = (link) => {
|
|
448
|
+
if (!link.href) return false;
|
|
449
|
+
const isExternalTarget = link.target && link.target !== "_self";
|
|
450
|
+
return !(link.hasAttribute(`${config.prefix}reload`) || link.hasAttribute("download") || isExternalTarget || !isSameOrigin(link.href));
|
|
451
|
+
};
|
|
452
|
+
var hoverDelay = 100;
|
|
453
|
+
var getPrefetchStrategy = (link) => {
|
|
454
|
+
const attr = link.getAttribute(`${config.prefix}prefetch`);
|
|
455
|
+
if (attr === null) return config.prefetch;
|
|
456
|
+
if (attr === "false") return false;
|
|
457
|
+
if (attr === "hover" || attr === "mousedown") return attr;
|
|
458
|
+
return config.prefetch || "hover";
|
|
459
|
+
};
|
|
460
|
+
var interceptPrefetch = () => {
|
|
461
|
+
let hoverTimeout;
|
|
462
|
+
let hoverLink;
|
|
463
|
+
const run = (link) => {
|
|
464
|
+
if (navigator.connection?.saveData) return;
|
|
465
|
+
const url = normalizeUrl(link.href);
|
|
466
|
+
if (url === currentUrl) return;
|
|
467
|
+
const regions = getVisitOptions(link).regions ?? config.regions;
|
|
468
|
+
prefetch(url, regions);
|
|
469
|
+
};
|
|
470
|
+
const findLink = (e) => {
|
|
471
|
+
const link = e.target?.closest?.("a");
|
|
472
|
+
if (!link || !getPrefetchStrategy(link) || !isInterceptable(link)) return;
|
|
473
|
+
return link;
|
|
474
|
+
};
|
|
475
|
+
const clearHover = () => {
|
|
476
|
+
clearTimeout(hoverTimeout);
|
|
477
|
+
hoverLink = void 0;
|
|
478
|
+
};
|
|
479
|
+
document.addEventListener("mouseover", (e) => {
|
|
480
|
+
const link = findLink(e);
|
|
481
|
+
if (!link || link === hoverLink || getPrefetchStrategy(link) !== "hover")
|
|
482
|
+
return;
|
|
483
|
+
clearHover();
|
|
484
|
+
hoverLink = link;
|
|
485
|
+
hoverTimeout = window.setTimeout(() => run(link), hoverDelay);
|
|
486
|
+
});
|
|
487
|
+
document.addEventListener("mouseout", (e) => {
|
|
488
|
+
const to = e.relatedTarget;
|
|
489
|
+
if (hoverLink && !(to && hoverLink.contains(to))) clearHover();
|
|
490
|
+
});
|
|
491
|
+
document.addEventListener("focusin", (e) => {
|
|
492
|
+
const link = findLink(e);
|
|
493
|
+
if (link && getPrefetchStrategy(link) === "hover") run(link);
|
|
494
|
+
});
|
|
495
|
+
document.addEventListener(
|
|
496
|
+
"touchstart",
|
|
497
|
+
(e) => {
|
|
498
|
+
const link = findLink(e);
|
|
499
|
+
if (link) run(link);
|
|
500
|
+
},
|
|
501
|
+
{ passive: true }
|
|
502
|
+
);
|
|
503
|
+
document.addEventListener("mousedown", (e) => {
|
|
504
|
+
if (e.button !== 0) return;
|
|
505
|
+
const link = findLink(e);
|
|
506
|
+
if (link) run(link);
|
|
507
|
+
});
|
|
508
|
+
};
|
|
348
509
|
var interceptForms = () => {
|
|
349
510
|
document.addEventListener("submit", (e) => {
|
|
350
511
|
const form = e.target;
|
|
@@ -374,10 +535,12 @@ var interceptForms = () => {
|
|
|
374
535
|
var interceptHistory = () => {
|
|
375
536
|
window.addEventListener("popstate", (event) => {
|
|
376
537
|
if (normalizeUrl(window.location.href) === normalizeUrl(currentUrl)) return;
|
|
538
|
+
const isBack = (event.state?.index ?? 0) < (currentState?.index ?? 0);
|
|
539
|
+
const regions = isBack ? currentState?.regions : event.state?.regions;
|
|
377
540
|
visit(window.location.href, {
|
|
378
541
|
history: "none",
|
|
379
542
|
isBackForward: true,
|
|
380
|
-
regions:
|
|
543
|
+
regions: regions ?? config.regions,
|
|
381
544
|
state: event.state
|
|
382
545
|
});
|
|
383
546
|
});
|
|
@@ -409,10 +572,13 @@ var getVisitOptions = (el) => {
|
|
|
409
572
|
// src/start.ts
|
|
410
573
|
var start = (options = {}) => {
|
|
411
574
|
Object.assign(config, options);
|
|
575
|
+
const url = normalizeUrl(window.location.href);
|
|
576
|
+
if (!cache.has(url)) cache.set(url, document.documentElement.outerHTML);
|
|
412
577
|
if (config.scrollBehavior) history.scrollRestoration = "manual";
|
|
413
578
|
interceptLinks();
|
|
414
579
|
interceptForms();
|
|
415
580
|
interceptHistory();
|
|
581
|
+
interceptPrefetch();
|
|
416
582
|
if (import.meta.env.DEV) {
|
|
417
583
|
document.addEventListener("ajax:before-visit", () => {
|
|
418
584
|
document.head.querySelectorAll("style[data-vite-dev-id]").forEach((el) => el.setAttribute("im-preserve", "true"));
|