@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 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,32 +1,68 @@
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
+ "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 progressDelayTimeout;
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
- toggleLoading(true);
26
- toggleProgress(true);
61
+ emit("progress-start", {});
27
62
  startTrickle();
28
63
  }, options.loadingDelay);
29
- const response = await fetch(url, {
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
- setProgress(0);
44
- const isAbortError = e instanceof DOMException && e.name === "AbortError";
45
- if (!isAbortError) {
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
- clearTimeout(progressDelayTimeout);
50
- stopTrickle();
51
- setProgress(1);
52
- toggleLoading(false);
53
- setTimeout(() => toggleProgress(false), options.progressHideDelay);
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
- const amount = -0.095 * progress + 0.1;
59
- setProgress(progress + Math.random() * amount);
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
- document.documentElement.style.setProperty(
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
- Array.from(
94
- newRegion.querySelectorAll(`[${escapedPrefix}permanent][id]`)
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?.(strategy === "replace" ? newRegion : region);
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 (newRegion.hasAttribute(`${prefix}autofocus`) || newRegion.autofocus) {
117
- autoFocusEl ??= newRegion;
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
- let autoFocusEls = [];
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(_, newNode) {
156
- if (newNode instanceof HTMLElement && (newNode.autofocus || newNode.hasAttribute(`${prefix}autofocus`))) {
157
- autoFocusEls.push(newNode);
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 emit = (type, payload) => {
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
- morphHeads = config.morphHeads,
298
+ regions = config.regions,
299
+ head = config.head,
227
300
  merge: mergeStrategy = config.merge,
228
- executeScripts: shouldExecuteScripts = config.executeScripts,
229
- viewTransitions = config.viewTransitions,
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
- state = { ...state, regions };
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 emit("before-visit", {
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) newDocument = parseHtml(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 emit("before-swap", { from, to, isBackForward, signal });
352
+ await emit2("before-swap", { from, to, isBackForward, signal });
269
353
  if (signal.aborted) return;
270
- if (morphHeads) Idiomorph2.morph(document.head, newDocument.head);
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 newRegion = newDocument.querySelector(id);
282
- if (!region || !newRegion) continue;
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(region, newRegion, strategy);
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 (config.render) {
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 emit("after-swap", { from, to, isBackForward, signal });
422
+ await emit2("after-swap", { from, to, isBackForward, signal });
321
423
  applyScrollBehavior();
322
- }).ready;
424
+ }).updateCallbackDone;
323
425
  } else {
426
+ updateHead();
324
427
  await mergeRegions();
325
- await emit("after-swap", { from, to, isBackForward, signal });
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
- emit("load", { from, to, isBackForward });
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.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;
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: currentState?.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"));
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.10",
4
4
  "description": "A very simple turbolinks inspired ajax library",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",