summaryrefslogtreecommitdiff
path: root/node_modules/swup/src/modules/navigate.ts
blob: ac88935b981c7f543212cc306c9c774e303c3e3a (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
import type Swup from '../Swup.js';
import { FetchError, type FetchOptions, type PageData } from './fetchPage.js';
import { type VisitInitOptions, type Visit, VisitState } from './Visit.js';
import { createHistoryRecord, updateHistoryRecord, Location, classify } from '../helpers.js';
import { getContextualAttr } from '../utils.js';

export type HistoryAction = 'push' | 'replace';
export type HistoryDirection = 'forwards' | 'backwards';
export type NavigationToSelfAction = 'scroll' | 'navigate';
export type CacheControl = Partial<{ read: boolean; write: boolean }>;

/** Define how to navigate to a page. */
type NavigationOptions = {
	/** Whether this visit is animated. Default: `true` */
	animate?: boolean;
	/** Name of a custom animation to run. */
	animation?: string;
	/** History action to perform: `push` for creating a new history entry, `replace` for replacing the current entry. Default: `push` */
	history?: HistoryAction;
	/** Whether this visit should read from or write to the cache. */
	cache?: CacheControl;
	/** Custom metadata associated with this visit. */
	meta?: Record<string, unknown>;
};

/**
 * Navigate to a new URL.
 * @param url The URL to navigate to.
 * @param options Options for how to perform this visit.
 * @returns Promise<void>
 */
export function navigate(
	this: Swup,
	url: string,
	options: NavigationOptions & FetchOptions = {},
	init: Omit<VisitInitOptions, 'to'> = {}
) {
	if (typeof url !== 'string') {
		throw new Error(`swup.navigate() requires a URL parameter`);
	}

	// Check if the visit should be ignored
	if (this.shouldIgnoreVisit(url, { el: init.el, event: init.event })) {
		window.location.assign(url);
		return;
	}

	const { url: to, hash } = Location.fromUrl(url);

	const visit = this.createVisit({ ...init, to, hash });
	this.performNavigation(visit, options);
}

/**
 * Start a visit to a new URL.
 *
 * Internal method that assumes the visit context has already been created.
 *
 * As a user, you should call `swup.navigate(url)` instead.
 *
 * @param url The URL to navigate to.
 * @param options Options for how to perform this visit.
 * @returns Promise<void>
 */
export async function performNavigation(
	this: Swup,
	visit: Visit,
	options: NavigationOptions & FetchOptions = {}
): Promise<void> {
	if (this.navigating) {
		if (this.visit.state >= VisitState.ENTERING) {
			// Currently navigating and content already loaded? Finish and queue
			visit.state = VisitState.QUEUED;
			this.onVisitEnd = () => this.performNavigation(visit, options);
			return;
		} else {
			// Currently navigating and content not loaded? Abort running visit
			await this.hooks.call('visit:abort', this.visit, undefined);
			delete this.visit.to.document;
			this.visit.state = VisitState.ABORTED;
		}
	}

	this.navigating = true;
	this.visit = visit;

	const { el } = visit.trigger;
	options.referrer = options.referrer || this.location.url;

	if (options.animate === false) {
		visit.animation.animate = false;
	}

	// Clean up old animation classes
	if (!visit.animation.animate) {
		this.classes.clear();
	}

	// Get history action from option or attribute on trigger element
	const history = options.history || getContextualAttr(el, 'data-swup-history');
	if (typeof history === 'string' && ['push', 'replace'].includes(history)) {
		visit.history.action = history as HistoryAction;
	}

	// Get custom animation name from option or attribute on trigger element
	const animation = options.animation || getContextualAttr(el, 'data-swup-animation');
	if (typeof animation === 'string') {
		visit.animation.name = animation;
	}

	// Get custom metadata from option
	visit.meta = options.meta || {};

	// Sanitize cache option
	if (typeof options.cache === 'object') {
		visit.cache.read = options.cache.read ?? visit.cache.read;
		visit.cache.write = options.cache.write ?? visit.cache.write;
	} else if (options.cache !== undefined) {
		visit.cache = { read: !!options.cache, write: !!options.cache };
	}
	// Delete this so that window.fetch doesn't mis-interpret it
	delete options.cache;

	try {
		await this.hooks.call('visit:start', visit, undefined);

		visit.state = VisitState.STARTED;

		// Begin loading page
		const page = this.hooks.call('page:load', visit, { options }, async (visit, args) => {
			// Read from cache
			let cachedPage: PageData | undefined;
			if (visit.cache.read) {
				cachedPage = this.cache.get(visit.to.url);
			}

			args.page = cachedPage || (await this.fetchPage(visit.to.url, args.options));
			args.cache = !!cachedPage;

			return args.page;
		});

		/**
		 * When the page is loaded: mark the visit as loaded and save
		 * the raw html and a parsed document of the received page in the visit object
		 */
		page.then(({ html }) => {
			visit.advance(VisitState.LOADED);
			visit.to.html = html;
			visit.to.document = new DOMParser().parseFromString(html, 'text/html');
		});

		// Create/update history record if this is not a popstate call or leads to the same URL
		const newUrl = visit.to.url + visit.to.hash;
		if (!visit.history.popstate) {
			if (visit.history.action === 'replace' || visit.to.url === this.location.url) {
				updateHistoryRecord(newUrl);
			} else {
				this.currentHistoryIndex++;
				createHistoryRecord(newUrl, { index: this.currentHistoryIndex });
			}
		}
		this.location = Location.fromUrl(newUrl);

		// Mark visit type with classes
		if (visit.history.popstate) {
			this.classes.add('is-popstate');
		}
		if (visit.animation.name) {
			this.classes.add(`to-${classify(visit.animation.name)}`);
		}

		// Wait for page before starting to animate out?
		if (visit.animation.wait) {
			await page;
		}

		// Check if failed/aborted in the meantime
		if (visit.done) return;

		// Perform the actual transition: animate and replace content
		await this.hooks.call('visit:transition', visit, undefined, async () => {
			// No animation? Just await page and render
			if (!visit.animation.animate) {
				await this.hooks.call('animation:skip', undefined);
				await this.renderPage(visit, await page);
				return;
			}

			// Animate page out, render page, animate page in
			visit.advance(VisitState.LEAVING);
			await this.animatePageOut(visit);
			if (visit.animation.native && document.startViewTransition) {
				await document.startViewTransition(
					async () => await this.renderPage(visit, await page)
				).finished;
			} else {
				await this.renderPage(visit, await page);
			}
			await this.animatePageIn(visit);
		});

		// Check if failed/aborted in the meantime
		if (visit.done) return;

		// Finalize visit
		await this.hooks.call('visit:end', visit, undefined, () => this.classes.clear());
		visit.state = VisitState.COMPLETED;
		this.navigating = false;

		/** Run eventually queued function */
		if (this.onVisitEnd) {
			this.onVisitEnd();
			this.onVisitEnd = undefined;
		}
	} catch (error) {
		// Return early if error is undefined or signals an aborted request
		if (!error || (error as FetchError)?.aborted) {
			visit.state = VisitState.ABORTED;
			return;
		}

		visit.state = VisitState.FAILED;

		// Log to console
		console.error(error);

		// Remove current history entry, then load requested url in browser
		this.options.skipPopStateHandling = () => {
			window.location.assign(visit.to.url + visit.to.hash);
			return true;
		};

		// Go back to the actual page we're still at
		window.history.back();
	} finally {
		delete visit.to.document;
	}
}