@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 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
- // Morph the `<head>` instead of leaving it untouched.
66
- morphHeads: true,
66
+ // How to update the `<head>`: 'merge', 'append', 'morph' or 'none'.
67
+ head: 'merge',
67
68
  // Re-execute `<script>` tags found in the swapped content.
68
69
  executeScripts: true,
69
- // Wrap navigations in the View Transitions API, if supported.
70
- viewTransitions: false,
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
- /** Morph the `<head>` instead of leaving it untouched. */
24
- morphHeads?: boolean;
25
- /** Re-execute `<script>` tags found in the swapped content. */
26
- executeScripts?: boolean;
27
- /** Wrap navigations in the View Transitions API, if supported. */
28
- viewTransitions?: boolean;
29
- /** Called instead of the default merge to render the new document. */
30
- render?: (newDocument: Document) => Promise<void> | void;
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, morphHeads, merge: mergeStrategy, executeScripts: shouldExecuteScripts, viewTransitions, loadingDelay, progressHideDelay, }?: VisitOptions) => Promise<void>;
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
- morphHeads: true,
3
+ head: "merge",
4
4
  merge: "replace",
5
5
  executeScripts: true,
6
- viewTransitions: false,
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 progressDelayTimeout;
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
- toggleLoading(true);
26
- toggleProgress(true);
62
+ emit("progress-start", {});
27
63
  startTrickle();
28
64
  }, options.loadingDelay);
29
- const response = await fetch(url, {
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
- setProgress(0);
44
- const isAbortError = e instanceof DOMException && e.name === "AbortError";
45
- if (!isAbortError) {
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
- clearTimeout(progressDelayTimeout);
50
- stopTrickle();
51
- setProgress(1);
52
- toggleLoading(false);
53
- setTimeout(() => toggleProgress(false), options.progressHideDelay);
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
- const amount = -0.095 * progress + 0.1;
59
- setProgress(progress + Math.random() * amount);
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
- document.documentElement.style.setProperty(
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
- Array.from(
94
- newRegion.querySelectorAll(`[${escapedPrefix}permanent][id]`)
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?.(strategy === "replace" ? newRegion : region);
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 (newRegion.hasAttribute(`${prefix}autofocus`) || newRegion.autofocus) {
117
- autoFocusEl ??= newRegion;
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
- let autoFocusEls = [];
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(_, newNode) {
156
- if (newNode instanceof HTMLElement && (newNode.autofocus || newNode.hasAttribute(`${prefix}autofocus`))) {
157
- autoFocusEls.push(newNode);
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 emit = (type, payload) => {
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
- morphHeads = config.morphHeads,
300
+ regions = config.regions,
301
+ head = config.head,
227
302
  merge: mergeStrategy = config.merge,
228
- executeScripts: shouldExecuteScripts = config.executeScripts,
229
- viewTransitions = config.viewTransitions,
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
- state = { ...state, regions };
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 emit("before-visit", {
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) newDocument = parseHtml(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 emit("before-swap", { from, to, isBackForward, signal });
354
+ await emit2("before-swap", { from, to, isBackForward, signal });
269
355
  if (signal.aborted) return;
270
- if (morphHeads) Idiomorph2.morph(document.head, newDocument.head);
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 newRegion = newDocument.querySelector(id);
282
- if (!region || !newRegion) continue;
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(region, newRegion, strategy);
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 (config.render) {
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 emit("after-swap", { from, to, isBackForward, signal });
424
+ await emit2("after-swap", { from, to, isBackForward, signal });
321
425
  applyScrollBehavior();
322
- }).ready;
426
+ }).updateCallbackDone;
323
427
  } else {
428
+ updateHead();
324
429
  await mergeRegions();
325
- await emit("after-swap", { from, to, isBackForward, signal });
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
- emit("load", { from, to, isBackForward });
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.href) return;
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: currentState?.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"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@very-simple/ajax",
3
- "version": "1.0.0-beta.1",
3
+ "version": "1.0.0-beta.11",
4
4
  "description": "A very simple turbolinks inspired ajax library",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",