Repository: swup/swup
Pull request #841 (2023-12-01 20:13:09)

Merge develop
- Prepare release
- Includes version bump
- Includes changelog update


Patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 091c0e0..c534354 100755
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,11 @@
 # Changelog
 
+## [4.5.0] - 2023-12
+
+- Support View Transitions API in new native mode
+- Handle rapid link clicks to achieve smooth transitions
+- Abort superseded visits with new `visit:abort` hook
+
 ## [4.4.4] - 2023-11-17
 
 - Dispatch DOM event `swup:any` every time a hook is run
@@ -20,7 +26,7 @@
 
 ## [4.4.0] - 2023-09-19
 
-- Enable experimental `ViewTransition` support
+- Enable experimental View Transition support
 - Extend test coverage to all major browsers
 - Add request timeout option
 
@@ -189,6 +195,7 @@ See [upgrade instructions](https://swup.js.org/getting-started/upgrading-v3/) fo
 
 - Fix bug where animateHistoryBrowsing option was ignored for OUT animations
 
+[4.5.0]: https://github.com/swup/swup/releases/tag/4.5.0
 [4.4.4]: https://github.com/swup/swup/releases/tag/4.4.4
 [4.4.3]: https://github.com/swup/swup/releases/tag/4.4.3
 [4.4.2]: https://github.com/swup/swup/releases/tag/4.4.3
diff --git a/package-lock.json b/package-lock.json
index c7fb593..32bee54 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "swup",
-  "version": "4.4.4",
+  "version": "4.5.0",
   "lockfileVersion": 3,
   "requires": true,
   "packages": {
     "": {
       "name": "swup",
-      "version": "4.4.4",
+      "version": "4.5.0",
       "hasInstallScript": true,
       "license": "MIT",
       "dependencies": {
@@ -19,6 +19,7 @@
         "@playwright/test": "^1.37.1",
         "@swup/browserslist-config": "^1.0.0",
         "@swup/prettier-config": "^1.0.0",
+        "@types/dom-view-transitions": "^1.0.2",
         "@types/jsdom": "^21.1.1",
         "@typescript-eslint/eslint-plugin": "^6.3.0",
         "@typescript-eslint/parser": "^6.3.0",
@@ -2965,6 +2966,12 @@
         "@types/chai": "*"
       }
     },
+    "node_modules/@types/dom-view-transitions": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/@types/dom-view-transitions/-/dom-view-transitions-1.0.4.tgz",
+      "integrity": "sha512-oDuagM6G+xPLrLU4KeCKlr1oalMF5mJqV5pDPMDVIEaa8AkUW00i6u+5P02XCjdEEUQJC9dpnxqSLsZeAciSLQ==",
+      "dev": true
+    },
     "node_modules/@types/estree": {
       "version": "0.0.39",
       "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz",
diff --git a/package.json b/package.json
index 29d9305..6a84851 100755
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
 {
   "name": "swup",
   "amdName": "Swup",
-  "version": "4.4.4",
+  "version": "4.5.0",
   "description": "Versatile and extensible page transition library for server-rendered websites",
   "type": "module",
   "source": "./src/Swup.ts",
@@ -67,6 +67,7 @@
     "@playwright/test": "^1.37.1",
     "@swup/browserslist-config": "^1.0.0",
     "@swup/prettier-config": "^1.0.0",
+    "@types/dom-view-transitions": "^1.0.2",
     "@types/jsdom": "^21.1.1",
     "@typescript-eslint/eslint-plugin": "^6.3.0",
     "@typescript-eslint/parser": "^6.3.0",
diff --git a/src/Swup.ts b/src/Swup.ts
index 328c0f4..3711ecf 100644
--- a/src/Swup.ts
+++ b/src/Swup.ts
@@ -21,7 +21,7 @@ import { renderPage } from './modules/renderPage.js';
 import { use, unuse, findPlugin, type Plugin } from './modules/plugins.js';
 import { isSameResolvedUrl, resolveUrl } from './modules/resolveUrl.js';
 import { nextTick } from './utils.js';
-import { type HistoryState } from './helpers/createHistoryRecord.js';
+import { type HistoryState } from './helpers/history.js';
 
 /** Options for customizing swup's behavior. */
 export type Options = {
@@ -41,6 +41,8 @@ export type Options = {
 	linkSelector: string;
 	/** How swup handles links to the same page. Default: `scroll` */
 	linkToSelf: NavigationToSelfAction;
+	/** Enable native animations using the View Transitions API. */
+	native: boolean;
 	/** Plugins to register on startup. */
 	plugins: Plugin[];
 	/** Custom headers sent along with fetch requests. */
@@ -62,6 +64,7 @@ const defaults: Options = {
 	ignoreVisit: (url, { el } = {}) => !!el?.closest('[data-no-swup]'),
 	linkSelector: 'a[href]',
 	linkToSelf: 'scroll',
+	native: false,
 	plugins: [],
 	resolveUrl: (url) => url,
 	requestHeaders: {
@@ -98,6 +101,8 @@ export default class Swup {
 	protected clickDelegate?: DelegateEventUnsubscribe;
 	/** Navigation status */
 	protected navigating: boolean = false;
+	/** Run anytime a visit ends */
+	protected onVisitEnd?: () => Promise<unknown>;
 
 	/** Install a plugin */
 	use = use;
@@ -185,6 +190,9 @@ export default class Swup {
 			// https://github.com/swup/swup/issues/475
 		}
 
+		// Sanitize/check native option
+		this.options.native = this.options.native && !!document.startViewTransition;
+
 		// Mount plugins
 		this.options.plugins.forEach((plugin) => this.use(plugin));
 
@@ -197,9 +205,10 @@ export default class Swup {
 		await nextTick();
 
 		// Trigger enable hook
-		await this.hooks.call('enable', undefined, () => {
-			// Add swup-enabled class to html tag
-			document.documentElement.classList.add('swup-enabled');
+		await this.hooks.call('enable', undefined, undefined, () => {
+			const html = document.documentElement;
+			html.classList.add('swup-enabled');
+			html.classList.toggle('swup-native', this.options.native);
 		});
 	}
 
@@ -218,9 +227,10 @@ export default class Swup {
 		this.options.plugins.forEach((plugin) => this.unuse(plugin));
 
 		// trigger disable hook
-		await this.hooks.call('disable', undefined, () => {
-			// remove swup-enabled class from html tag
-			document.documentElement.classList.remove('swup-enabled');
+		await this.hooks.call('disable', undefined, undefined, () => {
+			const html = document.documentElement;
+			html.classList.remove('swup-enabled');
+			html.classList.remove('swup-native');
 		});
 
 		// remove handlers
@@ -265,11 +275,11 @@ export default class Swup {
 			return;
 		}
 
-		this.visit = this.createVisit({ to: url, hash, el, event });
+		const visit = this.createVisit({ to: url, hash, el, event });
 
 		// Exit early if control key pressed
 		if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
-			this.hooks.call('link:newtab', { href });
+			this.hooks.callSync('link:newtab', visit, { href });
 			return;
 		}
 
@@ -278,8 +288,8 @@ export default class Swup {
 			return;
 		}
 
-		this.hooks.callSync('link:click', { el, event }, () => {
-			const from = this.visit.from.url ?? '';
+		this.hooks.callSync('link:click', visit, { el, event }, () => {
+			const from = visit.from.url ?? '';
 
 			event.preventDefault();
 
@@ -287,20 +297,18 @@ export default class Swup {
 			if (!url || url === from) {
 				if (hash) {
 					// With hash: scroll to anchor
-					this.hooks.callSync('link:anchor', { hash }, () => {
+					this.hooks.callSync('link:anchor', visit, { hash }, () => {
 						updateHistoryRecord(url + hash);
-						this.scrollToContent();
+						this.scrollToContent(visit);
 					});
 				} else {
 					// Without hash: scroll to top or load/reload page
-					this.hooks.callSync('link:self', undefined, () => {
-						switch (this.options.linkToSelf) {
-							case 'navigate':
-								return this.performNavigation();
-							case 'scroll':
-							default:
-								updateHistoryRecord(url);
-								return this.scrollToContent();
+					this.hooks.callSync('link:self', visit, undefined, () => {
+						if (this.options.linkToSelf === 'navigate') {
+							this.performNavigation(visit);
+						} else {
+							updateHistoryRecord(url);
+							this.scrollToContent(visit);
 						}
 					});
 				}
@@ -313,7 +321,7 @@ export default class Swup {
 			}
 
 			// Finally, proceed with loading the page
-			this.performNavigation();
+			this.performNavigation(visit);
 		});
 	}
 
@@ -332,37 +340,32 @@ export default class Swup {
 
 		const { url, hash } = Location.fromUrl(href);
 
-		this.visit = this.createVisit({ to: url, hash, event });
+		const visit = this.createVisit({ to: url, hash, event });
 
 		// Mark as history visit
-		this.visit.history.popstate = true;
+		visit.history.popstate = true;
 
 		// Determine direction of history visit
 		const index = (event.state as HistoryState)?.index ?? 0;
 		if (index && index !== this.currentHistoryIndex) {
 			const direction = index - this.currentHistoryIndex > 0 ? 'forwards' : 'backwards';
-			this.visit.history.direction = direction;
+			visit.history.direction = direction;
 			this.currentHistoryIndex = index;
 		}
 
 		// Disable animation & scrolling for history visits
-		this.visit.animation.animate = false;
-		this.visit.scroll.reset = false;
-		this.visit.scroll.target = false;
+		visit.animation.animate = false;
+		visit.scroll.reset = false;
+		visit.scroll.target = false;
 
 		// Animated history visit: re-enable animation & scroll reset
 		if (this.options.animateHistoryBrowsing) {
-			this.visit.animation.animate = true;
-			this.visit.scroll.reset = true;
+			visit.animation.animate = true;
+			visit.scroll.reset = true;
 		}
 
-		// Does this even do anything?
-		// if (!hash) {
-		// 	event.preventDefault();
-		// }
-
-		this.hooks.callSync('history:popstate', { event }, () => {
-			this.performNavigation();
+		this.hooks.callSync('history:popstate', visit, { event }, () => {
+			this.performNavigation(visit);
 		});
 	}
 
diff --git a/src/helpers.ts b/src/helpers.ts
index 9873e64..a4aef87 100644
--- a/src/helpers.ts
+++ b/src/helpers.ts
@@ -2,8 +2,7 @@
 // e.g. import { updateHistoryRecord } from 'swup'
 
 export { classify } from './helpers/classify.js';
-export { createHistoryRecord } from './helpers/createHistoryRecord.js';
-export { updateHistoryRecord } from './helpers/updateHistoryRecord.js';
+export { createHistoryRecord, updateHistoryRecord } from './helpers/history.js';
 export { delegateEvent } from './helpers/delegateEvent.js';
 export { getCurrentUrl } from './helpers/getCurrentUrl.js';
 export { Location } from './helpers/Location.js';
diff --git a/src/helpers/Location.ts b/src/helpers/Location.ts
index beae652..aca9d6b 100755
--- a/src/helpers/Location.ts
+++ b/src/helpers/Location.ts
@@ -6,6 +6,7 @@
 export class Location extends URL {
 	constructor(url: URL | string, base: string = document.baseURI) {
 		super(url.toString(), base);
+		// Fix Safari bug with extending native classes
 		Object.setPrototypeOf(this, Location.prototype);
 	}
 
diff --git a/src/helpers/createHistoryRecord.ts b/src/helpers/createHistoryRecord.ts
deleted file mode 100755
index 2a58dfc..0000000
--- a/src/helpers/createHistoryRecord.ts
+++ /dev/null
@@ -1,24 +0,0 @@
-import { getCurrentUrl } from './getCurrentUrl.js';
-
-export interface HistoryState {
-	url: string;
-	source: 'swup';
-	random: number;
-	index?: number;
-	[key: string]: unknown;
-}
-
-/** Create a new history record with a custom swup identifier. */
-export const createHistoryRecord = (
-	url: string,
-	customData: Record<string, unknown> = {}
-): void => {
-	url = url || getCurrentUrl({ hash: true });
-	const data: HistoryState = {
-		url,
-		random: Math.random(),
-		source: 'swup',
-		...customData
-	};
-	history.pushState(data, '', url);
-};
diff --git a/src/helpers/history.ts b/src/helpers/history.ts
new file mode 100755
index 0000000..9df4b97
--- /dev/null
+++ b/src/helpers/history.ts
@@ -0,0 +1,37 @@
+import { getCurrentUrl } from './getCurrentUrl.js';
+
+export interface HistoryState {
+	url: string;
+	source: 'swup';
+	random: number;
+	index?: number;
+	[key: string]: unknown;
+}
+
+type HistoryData = Record<string, unknown>;
+
+/** Create a new history record with a custom swup identifier. */
+export const createHistoryRecord = (url: string, data: HistoryData = {}): void => {
+	url = url || getCurrentUrl({ hash: true });
+	const state: HistoryState = {
+		url,
+		random: Math.random(),
+		source: 'swup',
+		...data
+	};
+	history.pushState(state, '', url);
+};
+
+/** Update the current history record with a custom swup identifier. */
+export const updateHistoryRecord = (url: string | null = null, data: HistoryData = {}): void => {
+	url = url || getCurrentUrl({ hash: true });
+	const currentState = (history.state as HistoryState) || {};
+	const state: HistoryState = {
+		...currentState,
+		url,
+		random: Math.random(),
+		source: 'swup',
+		...data
+	};
+	history.replaceState(state, '', url);
+};
diff --git a/src/helpers/updateHistoryRecord.ts b/src/helpers/updateHistoryRecord.ts
deleted file mode 100755
index e6e68a7..0000000
--- a/src/helpers/updateHistoryRecord.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import type { HistoryState } from './createHistoryRecord.js';
-import { getCurrentUrl } from './getCurrentUrl.js';
-
-/** Update the current history record with a custom swup identifier. */
-export const updateHistoryRecord = (
-	url: string | null = null,
-	customData: Record<string, unknown> = {}
-): void => {
-	url = url || getCurrentUrl({ hash: true });
-	const state = (history.state as HistoryState) || {};
-	const data: HistoryState = {
-		...state,
-		url,
-		random: Math.random(),
-		source: 'swup',
-		...customData
-	};
-	history.replaceState(data, '', url);
-};
diff --git a/src/modules/Cache.ts b/src/modules/Cache.ts
index 4aabe70..913e10a 100755
--- a/src/modules/Cache.ts
+++ b/src/modules/Cache.ts
@@ -49,7 +49,7 @@ export class Cache {
 		url = this.resolve(url);
 		page = { ...page, url };
 		this.pages.set(url, page);
-		this.swup.hooks.callSync('cache:set', { page });
+		this.swup.hooks.callSync('cache:set', undefined, { page });
 	}
 
 	/** Update a cache record, overwriting or adding custom data. */
@@ -67,7 +67,7 @@ export class Cache {
 	/** Empty the cache. */
 	clear(): void {
 		this.pages.clear();
-		this.swup.hooks.callSync('cache:clear', undefined);
+		this.swup.hooks.callSync('cache:clear', undefined, undefined);
 	}
 
 	/** Remove all cache entries that return true for a given predicate function.  */
diff --git a/src/modules/Classes.ts b/src/modules/Classes.ts
index f72dca1..7e03451 100644
--- a/src/modules/Classes.ts
+++ b/src/modules/Classes.ts
@@ -3,7 +3,14 @@ import { queryAll } from '../utils.js';
 
 export class Classes {
 	protected swup: Swup;
-	protected swupClasses = ['to-', 'is-changing', 'is-rendering', 'is-popstate', 'is-animating'];
+	protected swupClasses = [
+		'to-',
+		'is-changing',
+		'is-rendering',
+		'is-popstate',
+		'is-animating',
+		'is-leaving'
+	];
 
 	constructor(swup: Swup) {
 		this.swup = swup;
diff --git a/src/modules/Hooks.ts b/src/modules/Hooks.ts
index d9f347e..f716592 100644
--- a/src/modules/Hooks.ts
+++ b/src/modules/Hooks.ts
@@ -2,7 +2,7 @@ import type { DelegateEvent } from 'delegate-it';
 
 import type Swup from '../Swup.js';
 import { isPromise, runAsPromise } from '../utils.js';
-import type { Visit } from './Visit.js';
+import { Visit } from './Visit.js';
 import type { FetchOptions, PageData } from './fetchPage.js';
 
 export interface HookDefinitions {
@@ -33,16 +33,16 @@ export interface HookDefinitions {
 	'scroll:anchor': { hash: string; options: ScrollIntoViewOptions };
 	'visit:start': undefined;
 	'visit:transition': undefined;
+	'visit:abort': undefined;
 	'visit:end': undefined;
 }
 
 export interface HookReturnValues {
-	'content:scroll': Promise<boolean>;
+	'content:scroll': Promise<boolean> | boolean;
 	'fetch:request': Promise<Response>;
 	'page:load': Promise<PageData>;
 	'scroll:top': boolean;
 	'scroll:anchor': boolean;
-	'visit:transition': Promise<boolean>;
 }
 
 export type HookArguments<T extends HookName> = HookDefinitions[T];
@@ -154,6 +154,7 @@ export class Hooks {
 		'scroll:anchor',
 		'visit:start',
 		'visit:transition',
+		'visit:abort',
 		'visit:end'
 	];
 
@@ -333,20 +334,29 @@ export class Hooks {
 	 * Trigger a hook asynchronously, executing its default handler and all registered handlers.
 	 * Will execute all handlers in order and `await` any `Promise`s they return.
 	 * @param hook Name of the hook to trigger
+	 * @param visit The visit object this hook belongs to
 	 * @param args Arguments to pass to the handler
 	 * @param defaultHandler A default implementation of this hook to execute
 	 * @returns The resolved return value of the executed default handler
 	 */
+	// Overload: default order of arguments
+	async call<T extends HookName>(hook: T, visit: Visit | undefined, args: HookArguments<T>, defaultHandler?: HookDefaultHandler<T>): Promise<Awaited<ReturnType<HookDefaultHandler<T>>>>; // prettier-ignore
+	// Overload: legacy order of arguments, with visit missing
+	async call<T extends HookName>(hook: T, args: HookArguments<T>, defaultHandler?: HookDefaultHandler<T>): Promise<Awaited<ReturnType<HookDefaultHandler<T>>>>; // prettier-ignore
+	// Implementation
 	async call<T extends HookName>(
 		hook: T,
-		args: HookArguments<T>,
-		defaultHandler?: HookDefaultHandler<T>
+		arg1: Visit | HookArguments<T>,
+		arg2: HookArguments<T> | HookDefaultHandler<T>,
+		arg3?: HookDefaultHandler<T>
 	): Promise<Awaited<ReturnType<HookDefaultHandler<T>>>> {
+		const [visit, args, defaultHandler] = this.parseCallArgs(hook, arg1, arg2, arg3);
+
 		const { before, handler, after } = this.getHandlers(hook, defaultHandler);
-		await this.run(before, args);
-		const [result] = await this.run(handler, args);
-		await this.run(after, args);
-		this.dispatchDomEvent(hook, args);
+		await this.run(before, visit, args);
+		const [result] = await this.run(handler, visit, args);
+		await this.run(after, visit, args);
+		this.dispatchDomEvent(hook, visit, args);
 		return result;
 	}
 
@@ -354,23 +364,51 @@ export class Hooks {
 	 * Trigger a hook synchronously, executing its default handler and all registered handlers.
 	 * Will execute all handlers in order, but will **not** `await` any `Promise`s they return.
 	 * @param hook Name of the hook to trigger
+	 * @param visit The visit object this hook belongs to
 	 * @param args Arguments to pass to the handler
 	 * @param defaultHandler A default implementation of this hook to execute
 	 * @returns The (possibly unresolved) return value of the executed default handler
 	 */
+	// Overload: default order of arguments
+	callSync<T extends HookName>(hook: T, visit: Visit | undefined, args: HookArguments<T>, defaultHandler?: HookDefaultHandler<T>): ReturnType<HookDefaultHandler<T>>; // prettier-ignore
+	// Overload: legacy order of arguments, with visit missing
+	callSync<T extends HookName>(hook: T, args: HookArguments<T>, defaultHandler?: HookDefaultHandler<T>): ReturnType<HookDefaultHandler<T>>; // prettier-ignore
+	// Implementation
 	callSync<T extends HookName>(
 		hook: T,
-		args: HookArguments<T>,
-		defaultHandler?: HookDefaultHandler<T>
+		arg1: Visit | HookArguments<T>,
+		arg2: HookArguments<T> | HookDefaultHandler<T>,
+		arg3?: HookDefaultHandler<T>
 	): ReturnType<HookDefaultHandler<T>> {
+		const [visit, args, defaultHandler] = this.parseCallArgs(hook, arg1, arg2, arg3);
 		const { before, handler, after } = this.getHandlers(hook, defaultHandler);
-		this.runSync(before, args);
-		const [result] = this.runSync(handler, args);
-		this.runSync(after, args);
-		this.dispatchDomEvent(hook, args);
+		this.runSync(before, visit, args);
+		const [result] = this.runSync(handler, visit, args);
+		this.runSync(after, visit, args);
+		this.dispatchDomEvent(hook, visit, args);
 		return result;
 	}
 
+	/**
+	 * Parse the call arguments for call() and callSync() to allow legacy argument order.
+	 */
+	protected parseCallArgs<T extends HookName>(
+		hook: T,
+		arg1: Visit | HookArguments<T> | undefined,
+		arg2: HookArguments<T> | HookDefaultHandler<T>,
+		arg3?: HookDefaultHandler<T>
+	): [Visit | undefined, HookArguments<T>, HookDefaultHandler<T> | undefined] {
+		const isLegacyOrder =
+			!(arg1 instanceof Visit) && (typeof arg1 === 'object' || typeof arg2 === 'function');
+		if (isLegacyOrder) {
+			// Legacy positioning: arguments in second or handler passed in third place
+			return [undefined, arg1 as HookArguments<T>, arg2 as HookDefaultHandler<T>];
+		} else {
+			// Default positioning: visit passed in as first argument
+			return [arg1, arg2 as HookArguments<T>, arg3];
+		}
+	}
+
 	/**
 	 * Execute the handlers for a hook, in order, as `Promise`s that will be `await`ed.
 	 * @param registrations The registrations (handler + options) to execute
@@ -378,21 +416,25 @@ export class Hooks {
 	 */
 
 	// Overload: running HookDefaultHandler: expect HookDefaultHandler return type
-	protected async run<T extends HookName>(registrations: HookRegistration<T, HookDefaultHandler<T>>[], args: HookArguments<T>): Promise<Awaited<ReturnType<HookDefaultHandler<T>>>[]>; // prettier-ignore
+	protected async run<T extends HookName>(registrations: HookRegistration<T, HookDefaultHandler<T>>[], visit: Visit | undefined, args: HookArguments<T>): Promise<Awaited<ReturnType<HookDefaultHandler<T>>>[]>; // prettier-ignore
 	// Overload:  running user handler: expect no specific type
-	protected async run<T extends HookName>(registrations: HookRegistration<T>[], args: HookArguments<T>): Promise<unknown[]>; // prettier-ignore
+	protected async run<T extends HookName>(registrations: HookRegistration<T>[], visit: Visit | undefined, args: HookArguments<T>): Promise<unknown[]>; // prettier-ignore
 	// Implementation
 	protected async run<T extends HookName, R extends HookRegistration<T>[]>(
 		registrations: R,
+		visit: Visit | undefined,
 		args: HookArguments<T>
 	): Promise<Awaited<ReturnType<HookDefaultHandler<T>>> | unknown[]> {
 		const results = [];
 		for (const { hook, handler, defaultHandler, once } of registrations) {
-			const result = await runAsPromise(handler, [this.swup.visit, args, defaultHandler]);
+			if (visit?.done) continue;
+			if (once) this.off(hook, handler);
+			const result = await runAsPromise(handler, [
+				visit || this.swup.visit,
+				args,
+				defaultHandler
+			]);
 			results.push(result);
-			if (once) {
-				this.off(hook, handler);
-			}
 		}
 		return results;
 	}
@@ -404,17 +446,20 @@ export class Hooks {
 	 */
 
 	// Overload: running HookDefaultHandler: expect HookDefaultHandler return type
-	protected runSync<T extends HookName>(registrations: HookRegistration<T, HookDefaultHandler<T>>[], args: HookArguments<T> ): ReturnType<HookDefaultHandler<T>>[]; // prettier-ignore
+	protected runSync<T extends HookName>(registrations: HookRegistration<T, HookDefaultHandler<T>>[], visit: Visit | undefined, args: HookArguments<T> ): ReturnType<HookDefaultHandler<T>>[]; // prettier-ignore
 	// Overload: running user handler: expect no specific type
-	protected runSync<T extends HookName>(registrations: HookRegistration<T>[], args: HookArguments<T>): unknown[]; // prettier-ignore
+	protected runSync<T extends HookName>(registrations: HookRegistration<T>[], visit: Visit | undefined, args: HookArguments<T>): unknown[]; // prettier-ignore
 	// Implementation
 	protected runSync<T extends HookName, R extends HookRegistration<T>[]>(
 		registrations: R,
+		visit: Visit | undefined,
 		args: HookArguments<T>
 	): (ReturnType<HookDefaultHandler<T>> | unknown)[] {
 		const results = [];
 		for (const { hook, handler, defaultHandler, once } of registrations) {
-			const result = (handler as HookDefaultHandler<T>)(this.swup.visit, args, defaultHandler); // prettier-ignore
+			if (visit?.done) continue;
+			if (once) this.off(hook, handler);
+			const result = (handler as HookDefaultHandler<T>)(visit || this.swup.visit, args, defaultHandler); // prettier-ignore
 			results.push(result);
 			if (isPromise(result)) {
 				console.warn(
@@ -422,9 +467,6 @@ export class Hooks {
 						`Swup will not wait for it to resolve.`
 				);
 			}
-			if (once) {
-				this.off(hook, handler);
-			}
 		}
 		return results;
 	}
@@ -500,8 +542,14 @@ export class Hooks {
 	 * Dispatch a custom event on the `document` for a hook. Prefixed with `swup:`
 	 * @param hook Name of the hook.
 	 */
-	protected dispatchDomEvent<T extends HookName>(hook: T, args?: HookArguments<T>): void {
-		const detail: HookEventDetail = { hook, args, visit: this.swup.visit };
+	protected dispatchDomEvent<T extends HookName>(
+		hook: T,
+		visit: Visit | undefined,
+		args?: HookArguments<T>
+	): void {
+		if (visit?.done) return;
+
+		const detail: HookEventDetail = { hook, args, visit: visit || this.swup.visit };
 		document.dispatchEvent(
 			new CustomEvent<HookEventDetail>(`swup:any`, { detail, bubbles: true })
 		);
diff --git a/src/modules/Visit.ts b/src/modules/Visit.ts
index 035b935..194c9d3 100644
--- a/src/modules/Visit.ts
+++ b/src/modules/Visit.ts
@@ -2,27 +2,8 @@ import type Swup from '../Swup.js';
 import type { Options } from '../Swup.js';
 import type { HistoryAction, HistoryDirection } from './navigate.js';
 
-/** An object holding details about the current visit. */
-export interface Visit {
-	/** A unique ID to identify this visit */
-	id: number;
-	/** The previous page, about to leave */
-	from: VisitFrom;
-	/** The next page, about to enter */
-	to: VisitTo;
-	/** The content containers, about to be replaced */
-	containers: Options['containers'];
-	/** Information about animated page transitions */
-	animation: VisitAnimation;
-	/** What triggered this visit */
-	trigger: VisitTrigger;
-	/** Cache behavior for this visit */
-	cache: VisitCache;
-	/** Browser history behavior on this visit */
-	history: VisitHistory;
-	/** Scroll behavior on this visit */
-	scroll: VisitScroll;
-}
+/** See below for the class Visit {} definition */
+// export interface Visit {}
 
 export interface VisitFrom {
 	/** The URL of the previous page */
@@ -45,6 +26,8 @@ export interface VisitAnimation {
 	wait: boolean;
 	/** Name of a custom animation to run. */
 	name?: string;
+	/** Whether this animation uses the native browser ViewTransition API. Default: `false` */
+	native: boolean;
 	/** Elements on which to add animation classes. Default: `html` element */
 	scope: 'html' | 'containers' | string[];
 	/** Selector for detecting animation timing. Default: `[class*="transition-"]` */
@@ -89,39 +72,95 @@ export interface VisitInitOptions {
 	event?: Event;
 }
 
-/** Create a new visit object. */
-export function createVisit(
-	this: Swup,
-	{ to, from = this.currentPageUrl, hash, el, event }: VisitInitOptions
-): Visit {
-	return {
-		id: Math.random(),
-		from: { url: from },
-		to: { url: to, hash },
-		containers: this.options.containers,
-		animation: {
+/** @internal */
+export const VisitState = {
+	CREATED: 1,
+	QUEUED: 2,
+	STARTED: 3,
+	LEAVING: 4,
+	LOADED: 5,
+	ENTERING: 6,
+	COMPLETED: 7,
+	ABORTED: 8,
+	FAILED: 9
+} as const;
+
+/** @internal */
+export type VisitState = (typeof VisitState)[keyof typeof VisitState];
+
+/** An object holding details about the current visit. */
+export class Visit {
+	/** A unique ID to identify this visit */
+	id: number;
+	/** The current state of this visit @internal */
+	state: VisitState;
+	/** The previous page, about to leave */
+	from: VisitFrom;
+	/** The next page, about to enter */
+	to: VisitTo;
+	/** The content containers, about to be replaced */
+	containers: Options['containers'];
+	/** Information about animated page transitions */
+	animation: VisitAnimation;
+	/** What triggered this visit */
+	trigger: VisitTrigger;
+	/** Cache behavior for this visit */
+	cache: VisitCache;
+	/** Browser history behavior on this visit */
+	history: VisitHistory;
+	/** Scroll behavior on this visit */
+	scroll: VisitScroll;
+
+	constructor(swup: Swup, options: VisitInitOptions) {
+		const { to, from = swup.currentPageUrl, hash, el, event } = options;
+
+		this.id = Math.random();
+		this.state = VisitState.CREATED;
+		this.from = { url: from };
+		this.to = { url: to, hash };
+		this.containers = swup.options.containers;
+		this.animation = {
 			animate: true,
 			wait: false,
 			name: undefined,
-			scope: this.options.animationScope,
-			selector: this.options.animationSelector
-		},
-		trigger: {
-			el,
-			event
-		},
-		cache: {
-			read: this.options.cache,
-			write: this.options.cache
-		},
-		history: {
+			native: swup.options.native,
+			scope: swup.options.animationScope,
+			selector: swup.options.animationSelector
+		};
+		this.trigger = { el, event };
+		this.cache = {
+			read: swup.options.cache,
+			write: swup.options.cache
+		};
+		this.history = {
 			action: 'push',
 			popstate: false,
 			direction: undefined
-		},
-		scroll: {
+		};
+		this.scroll = {
 			reset: true,
 			target: undefined
+		};
+	}
+	/** @internal */
+	advance(state: VisitState) {
+		if (this.state < state) {
+			this.state = state;
 		}
-	};
+	}
+
+	/** @internal */
+	abort() {
+		this.state = VisitState.ABORTED;
+	}
+
+	/** Is this visit done, i.e. completed, failed, or aborted? */
+	get done(): boolean {
+		return this.state >= VisitState.COMPLETED;
+	}
+}
+
+/** Create a new visit object. */
+export function createVisit(this: Swup, options: VisitInitOptions): Visit {
+	return new Visit(this, options);
 }
diff --git a/src/modules/animatePageIn.ts b/src/modules/animatePageIn.ts
index ad4a444..fc384b7 100755
--- a/src/modules/animatePageIn.ts
+++ b/src/modules/animatePageIn.ts
@@ -1,31 +1,32 @@
 import type Swup from '../Swup.js';
 import { nextTick } from '../utils.js';
+import type { Visit } from './Visit.js';
 
 /**
  * Perform the in/enter animation of the next page.
  * @returns Promise<void>
  */
-export const animatePageIn = async function (this: Swup) {
-	if (!this.visit.animation.animate) {
-		return;
-	}
+export const animatePageIn = async function (this: Swup, visit: Visit) {
+	// Check if failed/aborted in the meantime
+	if (visit.done) return;
 
 	const animation = this.hooks.call(
 		'animation:in:await',
+		visit,
 		{ skip: false },
-		async (visit, { skip }) => {
+		(visit, { skip }) => {
 			if (skip) return;
-			await this.awaitAnimations({ selector: visit.animation.selector });
+			return this.awaitAnimations({ selector: visit.animation.selector });
 		}
 	);
 
 	await nextTick();
 
-	await this.hooks.call('animation:in:start', undefined, () => {
+	await this.hooks.call('animation:in:start', visit, undefined, () => {
 		this.classes.remove('is-animating');
 	});
 
 	await animation;
 
-	await this.hooks.call('animation:in:end', undefined);
+	await this.hooks.call('animation:in:end', visit, undefined);
 };
diff --git a/src/modules/animatePageOut.ts b/src/modules/animatePageOut.ts
index 81b931d..d42bf57 100755
--- a/src/modules/animatePageOut.ts
+++ b/src/modules/animatePageOut.ts
@@ -1,30 +1,19 @@
 import type Swup from '../Swup.js';
-import { classify } from '../helpers.js';
+import type { Visit } from './Visit.js';
 
 /**
  * Perform the out/leave animation of the current page.
  * @returns Promise<void>
  */
-export const animatePageOut = async function (this: Swup) {
-	if (!this.visit.animation.animate) {
-		await this.hooks.call('animation:skip', undefined);
-		return;
-	}
-
-	await this.hooks.call('animation:out:start', undefined, (visit) => {
-		this.classes.add('is-changing', 'is-leaving', 'is-animating');
-		if (visit.history.popstate) {
-			this.classes.add('is-popstate');
-		}
-		if (visit.animation.name) {
-			this.classes.add(`to-${classify(visit.animation.name)}`);
-		}
+export const animatePageOut = async function (this: Swup, visit: Visit) {
+	await this.hooks.call('animation:out:start', visit, undefined, () => {
+		this.classes.add('is-changing', 'is-animating', 'is-leaving');
 	});
 
-	await this.hooks.call('animation:out:await', { skip: false }, async (visit, { skip }) => {
+	await this.hooks.call('animation:out:await', visit, { skip: false }, (visit, { skip }) => {
 		if (skip) return;
-		await this.awaitAnimations({ selector: visit.animation.selector });
+		return this.awaitAnimations({ selector: visit.animation.selector });
 	});
 
-	await this.hooks.call('animation:out:end', undefined);
+	await this.hooks.call('animation:out:end', visit, undefined);
 };
diff --git a/src/modules/fetchPage.ts b/src/modules/fetchPage.ts
index bdb2687..9627544 100644
--- a/src/modules/fetchPage.ts
+++ b/src/modules/fetchPage.ts
@@ -1,5 +1,6 @@
 import type Swup from '../Swup.js';
 import { Location } from '../helpers.js';
+import type { Visit } from './Visit.js';
 
 /** A page object as used by swup and its cache. */
 export interface PageData {
@@ -17,6 +18,8 @@ export interface FetchOptions extends Omit<RequestInit, 'cache'> {
 	body?: string | FormData | URLSearchParams;
 	/** The request timeout in milliseconds. */
 	timeout?: number;
+	/** Optional visit object with additional context. @internal */
+	visit?: Visit;
 }
 
 export class FetchError extends Error {
@@ -47,6 +50,7 @@ export async function fetchPage(
 ): Promise<PageData> {
 	url = Location.fromUrl(url).url;
 
+	const { visit = this.visit } = options;
 	const headers = { ...this.options.requestHeaders, ...options.headers };
 	const timeout = options.timeout ?? this.options.timeout;
 	const controller = new AbortController();
@@ -67,6 +71,7 @@ export async function fetchPage(
 	try {
 		response = await this.hooks.call(
 			'fetch:request',
+			visit,
 			{ url, options },
 			(visit, { url, options }) => fetch(url, options)
 		);
@@ -75,14 +80,11 @@ export async function fetchPage(
 		}
 	} catch (error) {
 		if (timedOut) {
-			this.hooks.call('fetch:timeout', { url });
+			this.hooks.call('fetch:timeout', visit, { url });
 			throw new FetchError(`Request timed out: ${url}`, { url, timedOut });
 		}
 		if ((error as Error)?.name === 'AbortError' || signal.aborted) {
-			throw new FetchError(`Request aborted: ${url}`, {
-				url: url,
-				aborted: true
-			});
+			throw new FetchError(`Request aborted: ${url}`, { url, aborted: true });
 		}
 		throw error;
 	}
@@ -91,7 +93,7 @@ export async function fetchPage(
 	const html = await response.text();
 
 	if (status === 500) {
-		this.hooks.call('fetch:error', { status, response, url: responseUrl });
+		this.hooks.call('fetch:error', visit, { status, response, url: responseUrl });
 		throw new FetchError(`Server error: ${responseUrl}`, { status, url: responseUrl });
 	}
 
@@ -104,11 +106,7 @@ export async function fetchPage(
 	const page = { url: finalUrl, html };
 
 	// Write to cache for safe methods and non-redirects
-	if (
-		this.visit.cache.write &&
-		(!options.method || options.method === 'GET') &&
-		url === finalUrl
-	) {
+	if (visit.cache.write && (!options.method || options.method === 'GET') && url === finalUrl) {
 		this.cache.set(page.url, page);
 	}
 
diff --git a/src/modules/navigate.ts b/src/modules/navigate.ts
index 0a459fb..c97569b 100755
--- a/src/modules/navigate.ts
+++ b/src/modules/navigate.ts
@@ -1,7 +1,13 @@
 import type Swup from '../Swup.js';
-import { createHistoryRecord, updateHistoryRecord, getCurrentUrl, Location } from '../helpers.js';
 import { FetchError, type FetchOptions, type PageData } from './fetchPage.js';
-import type { VisitInitOptions } from './Visit.js';
+import { type VisitInitOptions, type Visit, VisitState } from './Visit.js';
+import {
+	createHistoryRecord,
+	updateHistoryRecord,
+	getCurrentUrl,
+	Location,
+	classify
+} from '../helpers.js';
 
 export type HistoryAction = 'push' | 'replace';
 export type HistoryDirection = 'forwards' | 'backwards';
@@ -43,8 +49,9 @@ export function navigate(
 	}
 
 	const { url: to, hash } = Location.fromUrl(url);
-	this.visit = this.createVisit({ ...init, to, hash });
-	this.performNavigation(options);
+
+	const visit = this.createVisit({ ...init, to, hash });
+	this.performNavigation(visit, options);
 }
 
 /**
@@ -60,12 +67,24 @@ export function navigate(
  */
 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);
+			this.visit.state = VisitState.ABORTED;
+		}
+	}
+
 	this.navigating = true;
-	// Save this localy to a) allow ignoring the visit if a new one was started in the meantime
-	// and b) avoid unintended modifications to any newer visits
-	const visit = this.visit;
+	this.visit = visit;
 
 	const { el } = visit.trigger;
 	options.referrer = options.referrer || this.currentPageUrl;
@@ -102,10 +121,11 @@ export async function performNavigation(
 	delete options.cache;
 
 	try {
-		await this.hooks.call('visit:start', undefined);
+		await this.hooks.call('visit:start', visit, undefined);
+		visit.state = VisitState.STARTED;
 
 		// Begin loading page
-		const pagePromise = this.hooks.call('page:load', { options }, async (visit, args) => {
+		const page = this.hooks.call('page:load', visit, { options }, async (visit, args) => {
 			// Read from cache
 			let cachedPage: PageData | undefined;
 			if (visit.cache.read) {
@@ -118,6 +138,12 @@ export async function performNavigation(
 			return args.page;
 		});
 
+		// When page loaded: mark visit as loaded, save html into visit object
+		page.then(({ html }) => {
+			visit.advance(VisitState.LOADED);
+			visit.to.html = html;
+		});
+
 		// Create/update history record if this is not a popstate call or leads to the same URL
 		if (!visit.history.popstate) {
 			// Add the hash directly from the trigger element
@@ -132,48 +158,66 @@ export async function performNavigation(
 
 		this.currentPageUrl = getCurrentUrl();
 
+		// 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) {
-			const { html } = await pagePromise;
-			visit.to.html = html;
+			await page;
 		}
 
-		// perform the actual transition: animate and replace content
-		await this.hooks.call('visit:transition', undefined, async (visit) => {
-			// Start leave animation
-			const animationPromise = this.animatePageOut();
+		// Check if failed/aborted in the meantime
+		if (visit.done) return;
 
-			// Wait for page to load and leave animation to finish
-			const [page] = await Promise.all([pagePromise, animationPromise]);
-
-			// Abort if another visit was started in the meantime
-			if (visit.id !== this.visit.id) {
-				return false;
+		// 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;
 			}
 
-			// Render page: replace content and scroll to top/fragment
-			await this.renderPage(page);
-
-			// Wait for enter animation
-			await this.animatePageIn();
-
-			return true;
+			// 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);
 		});
 
-		// Finalize visit
-		await this.hooks.call('visit:end', undefined, () => this.classes.clear());
+		// Check if failed/aborted in the meantime
+		if (visit.done) return;
 
-		// Reset visit info after finish?
-		// if (visit.to && this.isSameResolvedUrl(visit.to.url, requestedUrl)) {
-		// 	this.visit = this.createVisit({ to: undefined });
-		// }
+		// 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 as we swallow almost all hook errors
 		console.error(error);
 
diff --git a/src/modules/renderPage.ts b/src/modules/renderPage.ts
index b6ca946..f4a6c88 100755
--- a/src/modules/renderPage.ts
+++ b/src/modules/renderPage.ts
@@ -1,12 +1,18 @@
 import { updateHistoryRecord, getCurrentUrl, classify } from '../helpers.js';
 import type Swup from '../Swup.js';
 import type { PageData } from './fetchPage.js';
+import { VisitState, type Visit } from './Visit.js';
 
 /**
  * Render the next page: replace the content and update scroll position.
  */
-export const renderPage = async function (this: Swup, page: PageData): Promise<void> {
-	const { url, html } = page;
+export const renderPage = async function (this: Swup, visit: Visit, page: PageData): Promise<void> {
+	// Check if failed/aborted in the meantime
+	if (visit.done) return;
+
+	visit.advance(VisitState.ENTERING);
+
+	const { url } = page;
 
 	this.classes.remove('is-leaving');
 
@@ -14,26 +20,23 @@ export const renderPage = async function (this: Swup, page: PageData): Promise<v
 	if (!this.isSameResolvedUrl(getCurrentUrl(), url)) {
 		updateHistoryRecord(url);
 		this.currentPageUrl = getCurrentUrl();
-		this.visit.to.url = this.currentPageUrl;
+		visit.to.url = this.currentPageUrl;
 	}
 
 	// only add for animated page loads
-	if (this.visit.animation.animate) {
+	if (visit.animation.animate) {
 		this.classes.add('is-rendering');
 	}
 
-	// save html into visit context for easier retrieval
-	this.visit.to.html = html;
-
 	// replace content: allow handlers and plugins to overwrite paga data and containers
-	await this.hooks.call('content:replace', { page }, (visit, { page }) => {
+	await this.hooks.call('content:replace', visit, { page }, (visit, { page }) => {
 		const success = this.replaceContent(page, { containers: visit.containers });
 		if (!success) {
 			throw new Error('[swup] Container mismatch, aborting');
 		}
 		if (visit.animation.animate) {
 			// Make sure to add these classes to new containers as well
-			this.classes.add('is-animating', 'is-changing', 'is-rendering');
+			this.classes.add('is-changing', 'is-animating', 'is-rendering');
 			if (visit.animation.name) {
 				this.classes.add(`to-${classify(visit.animation.name)}`);
 			}
@@ -41,10 +44,9 @@ export const renderPage = async function (this: Swup, page: PageData): Promise<v
 	});
 
 	// scroll into view: either anchor or top of page
-	// @ts-ignore: not returning a promise is intentional to allow users to pause in handler
-	await this.hooks.call('content:scroll', undefined, () => {
-		return this.scrollToContent();
+	await this.hooks.call('content:scroll', visit, undefined, () => {
+		return this.scrollToContent(visit);
 	});
 
-	await this.hooks.call('page:view', { url: this.currentPageUrl, title: document.title });
+	await this.hooks.call('page:view', visit, { url: this.currentPageUrl, title: document.title });
 };
diff --git a/src/modules/replaceContent.ts b/src/modules/replaceContent.ts
index 21f4cfd..8859b95 100755
--- a/src/modules/replaceContent.ts
+++ b/src/modules/replaceContent.ts
@@ -53,5 +53,6 @@ export const replaceContent = function (
 		}
 	});
 
+	// Return true if all containers were replaced
 	return replaced.length === containers.length;
 };
diff --git a/src/modules/scrollToContent.ts b/src/modules/scrollToContent.ts
index 7821d63..0c081a2 100755
--- a/src/modules/scrollToContent.ts
+++ b/src/modules/scrollToContent.ts
@@ -1,19 +1,21 @@
 import type Swup from '../Swup.js';
+import type { Visit } from './Visit.js';
 
 /**
  * Update the scroll position after page render.
  * @returns Promise<boolean>
  */
-export const scrollToContent = function (this: Swup): boolean {
+export const scrollToContent = function (this: Swup, visit: Visit): boolean {
 	const options: ScrollIntoViewOptions = { behavior: 'auto' };
-	const { target, reset } = this.visit.scroll;
-	const scrollTarget = target ?? this.visit.to.hash;
+	const { target, reset } = visit.scroll;
+	const scrollTarget = target ?? visit.to.hash;
 
 	let scrolled = false;
 
 	if (scrollTarget) {
 		scrolled = this.hooks.callSync(
 			'scroll:anchor',
+			visit,
 			{ hash: scrollTarget, options },
 			(visit, { hash, options }) => {
 				const anchor = this.getAnchorElement(hash);
@@ -26,7 +28,7 @@ export const scrollToContent = function (this: Swup): boolean {
 	}
 
 	if (reset && !scrolled) {
-		scrolled = this.hooks.callSync('scroll:top', { options }, (visit, { options }) => {
+		scrolled = this.hooks.callSync('scroll:top', visit, { options }, (visit, { options }) => {
 			window.scrollTo({ top: 0, left: 0, ...options });
 			return true;
 		});
diff --git a/tsconfig.json b/tsconfig.json
index 23d4cb8..9faa8f2 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -24,6 +24,8 @@
     /* If you're building for a library: */
     "declaration": true,
     /* If you're building for a library in a monorepo: */
-    "declarationMap": true
+    "declarationMap": true,
+    /* Don't advertise @private properties */
+    "stripInternal": true
   },
 }


Test patch:
diff --git a/tests/fixtures/animation-complex.html b/tests/fixtures/animation-complex.html
index 4b5921d..b736cf5 100644
--- a/tests/fixtures/animation-complex.html
+++ b/tests/fixtures/animation-complex.html
@@ -23,6 +23,7 @@
             <li><a href="/animation-duration.html">Duration</a></li>
             <li><a href="/animation-complex.html">Complex</a></li>
             <li><a href="/animation-keyframes.html">Keyframes</a></li>
+            <li><a href="/animation-native.html">Native</a></li>
             <li><a href="/animation-partial.html">Partial</a></li>
             <li><a href="/animation-none.html">None</a></li>
         </ul>
diff --git a/tests/fixtures/animation-duration.html b/tests/fixtures/animation-duration.html
index b1b8962..0441ffb 100644
--- a/tests/fixtures/animation-duration.html
+++ b/tests/fixtures/animation-duration.html
@@ -22,6 +22,7 @@
             <li><a href="/animation-duration.html">Duration</a></li>
             <li><a href="/animation-complex.html">Complex</a></li>
             <li><a href="/animation-keyframes.html">Keyframes</a></li>
+            <li><a href="/animation-native.html">Native</a></li>
             <li><a href="/animation-partial.html">Partial</a></li>
             <li><a href="/animation-none.html">None</a></li>
         </ul>
diff --git a/tests/fixtures/animation-keyframes.html b/tests/fixtures/animation-keyframes.html
index 1da6f27..8370589 100644
--- a/tests/fixtures/animation-keyframes.html
+++ b/tests/fixtures/animation-keyframes.html
@@ -8,9 +8,7 @@
     <script src="/dist/Swup.umd.js"></script>
     <style>
         html.is-changing .transition-custom {
-            animation: page-animation 400ms 300ms;
-            animation-fill-mode: both;
-            transition: none;
+            animation: 400ms 300ms ease-in-out both page-animation;
         }
         html.is-rendering .transition-custom {
             animation-direction: reverse;
@@ -33,6 +31,7 @@
             <li><a href="/animation-duration.html">Duration</a></li>
             <li><a href="/animation-complex.html">Complex</a></li>
             <li><a href="/animation-keyframes.html">Keyframes</a></li>
+            <li><a href="/animation-native.html">Native</a></li>
             <li><a href="/animation-partial.html">Partial</a></li>
             <li><a href="/animation-none.html">None</a></li>
         </ul>
diff --git a/tests/fixtures/animation-native.html b/tests/fixtures/animation-native.html
new file mode 100644
index 0000000..7bc7a09
--- /dev/null
+++ b/tests/fixtures/animation-native.html
@@ -0,0 +1,54 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="utf-8">
+    <meta name="viewport" content="width=device-width, initial-scale=1">
+    <title>Animation duration: native</title>
+    <link rel="stylesheet" href="/assets/main.css">
+    <script src="/dist/Swup.umd.js"></script>
+    <style>
+        html.is-changing .transition-custom {
+            view-transition-name: main;
+        }
+
+        ::view-transition-old(main) {
+            animation: 0.5s ease-in-out both page-animation;
+        }
+
+        ::view-transition-new(main) {
+            animation: 0.5s ease-in-out both reverse page-animation;
+        }
+
+        @keyframes page-animation {
+            from { opacity: 1; }
+            to { opacity: 0; }
+        }
+
+        html.is-changing:not(.swup-native) .transition-custom {
+            transition: 0.4s opacity;
+            opacity: 1;
+        }
+        html.is-animating:not(.swup-native) .transition-custom {
+            opacity: 0;
+        }
+    </style>
+</head>
+<body>
+    <header class="header">
+        <ul>
+            <li><a href="/animation-duration.html">Duration</a></li>
+            <li><a href="/animation-complex.html">Complex</a></li>
+            <li><a href="/animation-keyframes.html">Keyframes</a></li>
+            <li><a href="/animation-native.html">Native</a></li>
+            <li><a href="/animation-partial.html">Partial</a></li>
+            <li><a href="/animation-none.html">None</a></li>
+        </ul>
+    </header>
+    <main id="swup" class="wrapper transition-custom">
+        <h1>Animation duration: native</h1>
+    </main>
+    <script>
+        window._swup = new Swup({ native: true });
+    </script>
+</body>
+</html>
diff --git a/tests/fixtures/animation-none.html b/tests/fixtures/animation-none.html
index 4cd8fc1..be8e2f2 100644
--- a/tests/fixtures/animation-none.html
+++ b/tests/fixtures/animation-none.html
@@ -18,6 +18,7 @@
             <li><a href="/animation-duration.html">Duration</a></li>
             <li><a href="/animation-complex.html">Complex</a></li>
             <li><a href="/animation-keyframes.html">Keyframes</a></li>
+            <li><a href="/animation-native.html">Native</a></li>
             <li><a href="/animation-partial.html">Partial</a></li>
             <li><a href="/animation-none.html">None</a></li>
         </ul>
diff --git a/tests/fixtures/animation-partial.html b/tests/fixtures/animation-partial.html
index 853ee86..2115b43 100644
--- a/tests/fixtures/animation-partial.html
+++ b/tests/fixtures/animation-partial.html
@@ -22,6 +22,7 @@
             <li><a href="/animation-duration.html">Duration</a></li>
             <li><a href="/animation-complex.html">Complex</a></li>
             <li><a href="/animation-keyframes.html">Keyframes</a></li>
+            <li><a href="/animation-native.html">Native</a></li>
             <li><a href="/animation-partial.html">Partial</a></li>
             <li><a href="/animation-none.html">None</a></li>
         </ul>
diff --git a/tests/fixtures/rapid-navigation/page-1.html b/tests/fixtures/rapid-navigation/page-1.html
new file mode 100644
index 0000000..a6a12d0
--- /dev/null
+++ b/tests/fixtures/rapid-navigation/page-1.html
@@ -0,0 +1,63 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<meta charset="utf-8" />
+		<meta name="viewport" content="width=device-width, initial-scale=1" />
+		<title>Rapid Navigation Page 1</title>
+		<link rel="stylesheet" href="/assets/main.css" />
+		<style>
+			html.is-changing .transition-default {
+				transition-property: opacity, transform;
+				transition-duration: 600ms;
+			}
+			html.is-animating .transition-default {
+				opacity: 0;
+				transform: translateY(-50px);
+			}
+			html.is-leaving .transition-default {
+				transform: translateY(50px);
+			}
+		</style>
+		<script src="/dist/Swup.umd.js"></script>
+	</head>
+	<body>
+		<header class="header">
+			<ul>
+				<li><a href="/rapid-navigation/page-1.html" data-testid="link-to-page-1">Page 1</a></li>
+				<li><a href="/rapid-navigation/page-2.html" data-testid="link-to-page-2">Page 2</a></li>
+				<li><a href="/rapid-navigation/page-3.html" data-testid="link-to-page-3">Page 3</a></li>
+			</ul>
+		</header>
+		<main id="swup" class="wrapper transition-default" data-testid="container">
+			<div class="wrapper-inner">
+				<h1>Rapid Navigation Page 1</h1>
+				<p>
+					Lorem ipsum dolor sit amet, consectetur adipisicing elit. Alias assumenda
+					consectetur consequatur enim esse incidunt iusto, magnam maxime molestias nisi
+					perferendis perspiciatis quibusdam quos repellat, vel veniam vero voluptate
+					voluptatem.
+				</p>
+			</div>
+		</main>
+		<script>
+			window.data = { received: [] };
+
+			window._swup = new Swup({ cache: false });
+
+			window.addEventListener('swup:any', (event) => {
+				const { hook } = event.detail;
+
+				if (hook === 'visit:start') {
+					document.documentElement.setAttribute('aria-busy', 'true');
+					window.data.received = [];
+				}
+
+				window.data.received.push(hook);
+
+				if (hook === 'visit:end') {
+					document.documentElement.removeAttribute('aria-busy');
+				}
+			});
+		</script>
+	</body>
+</html>
diff --git a/tests/fixtures/rapid-navigation/page-2.html b/tests/fixtures/rapid-navigation/page-2.html
new file mode 100644
index 0000000..a00d687
--- /dev/null
+++ b/tests/fixtures/rapid-navigation/page-2.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<meta charset="utf-8" />
+		<meta name="viewport" content="width=device-width, initial-scale=1" />
+		<title>Rapid Navigation Page 2</title>
+		<link rel="stylesheet" href="/assets/main.css" />
+		<script src="/dist/Swup.umd.js"></script>
+	</head>
+	<body>
+		<header class="header">
+			<ul>
+				<li><a href="/rapid-navigation/page-1.html" data-testid="link-to-page-1">Page 1</a></li>
+				<li><a href="/rapid-navigation/page-2.html" data-testid="link-to-page-2">Page 2</a></li>
+				<li><a href="/rapid-navigation/page-3.html" data-testid="link-to-page-3">Page 3</a></li>
+			</ul>
+		</header>
+		<main id="swup" class="wrapper transition-default" data-testid="container">
+			<div class="wrapper-inner">
+				<h1>Rapid Navigation Page 2</h1>
+				<p>
+					Lorem ipsum dolor sit amet, consectetur adipisicing elit. Alias assumenda
+					consectetur consequatur enim esse incidunt iusto, magnam maxime molestias nisi
+					perferendis perspiciatis quibusdam quos repellat, vel veniam vero voluptate
+					voluptatem.
+				</p>
+			</div>
+		</main>
+	</body>
+</html>
diff --git a/tests/fixtures/rapid-navigation/page-3.html b/tests/fixtures/rapid-navigation/page-3.html
new file mode 100644
index 0000000..53ccaf2
--- /dev/null
+++ b/tests/fixtures/rapid-navigation/page-3.html
@@ -0,0 +1,30 @@
+<!DOCTYPE html>
+<html lang="en">
+	<head>
+		<meta charset="utf-8" />
+		<meta name="viewport" content="width=device-width, initial-scale=1" />
+		<title>Rapid Navigation Page 3</title>
+		<link rel="stylesheet" href="/assets/main.css" />
+		<script src="/dist/Swup.umd.js"></script>
+	</head>
+	<body>
+		<header class="header">
+			<ul>
+				<li><a href="/rapid-navigation/page-1.html" data-testid="link-to-page-1">Page 1</a></li>
+				<li><a href="/rapid-navigation/page-2.html" data-testid="link-to-page-2">Page 2</a></li>
+				<li><a href="/rapid-navigation/page-3.html" data-testid="link-to-page-3">Page 3</a></li>
+			</ul>
+		</header>
+		<main id="swup" class="wrapper transition-default" data-testid="container">
+			<div class="wrapper-inner">
+				<h1>Rapid Navigation Page 3</h1>
+				<p>
+					Lorem ipsum dolor sit amet, consectetur adipisicing elit. Alias assumenda
+					consectetur consequatur enim esse incidunt iusto, magnam maxime molestias nisi
+					perferendis perspiciatis quibusdam quos repellat, vel veniam vero voluptate
+					voluptatem.
+				</p>
+			</div>
+		</main>
+	</body>
+</html>
diff --git a/tests/functional/animation-timing.spec.ts b/tests/functional/animation-timing.spec.ts
index e80fbe1..abb6a56 100644
--- a/tests/functional/animation-timing.spec.ts
+++ b/tests/functional/animation-timing.spec.ts
@@ -3,20 +3,20 @@ import { test } from '@playwright/test';
 import { expectSwupAnimationDuration } from '../support/swup.js';
 
 test.describe('animation timing', () => {
-	test.skip(({ browserName }) => browserName == 'webkit', 'WebKit measurements are off');
+	test.skip(({ browserName }) => browserName === 'webkit', 'WebKit measurements are off');
 
 	test('detects animation timing', async ({ page }) => {
 		await page.goto('/animation-duration.html');
-		await expectSwupAnimationDuration(page, 400);
+		await expectSwupAnimationDuration(page, { out: 400, in: 400, total: 800 });
 	});
 
 	test('detects complex animation timing', async ({ page }) => {
 		await page.goto('/animation-complex.html');
-		await expectSwupAnimationDuration(page, 600);
+		await expectSwupAnimationDuration(page, { out: 600, in: 600, total: 1200 });
 	});
 
 	test('detects keyframe timing', async ({ page }) => {
 		await page.goto('/animation-keyframes.html');
-		await expectSwupAnimationDuration(page, 700);
+		await expectSwupAnimationDuration(page, { out: 700, in: 700, total: 1400 });
 	});
 });
diff --git a/tests/functional/history.spec.ts b/tests/functional/history.spec.ts
index 1fa3a60..81d0923 100644
--- a/tests/functional/history.spec.ts
+++ b/tests/functional/history.spec.ts
@@ -18,6 +18,7 @@ test.describe('history', () => {
 		await navigateWithSwup(page, '/page-2.html');
 		await expectToBeAt(page, '/page-2.html', 'Page 2');
 		await page.goBack();
+		await expectToBeAt(page, '/history.html', 'History');
 		const state = await page.evaluate(() => window.history.state);
 		await navigateWithSwup(page, '/page-2.html');
 		await expectToBeAt(page, '/page-2.html', 'Page 2');
@@ -31,6 +32,7 @@ test.describe('history', () => {
 		await navigateWithSwup(page, '/page-2.html');
 		await expectToBeAt(page, '/page-2.html', 'Page 2');
 		await page.goBack();
+		await expectToBeAt(page, '/history.html', 'History');
 		const state = await page.evaluate(() => window.history.state);
 		await navigateWithSwup(page, '/page-2.html');
 		await expectToBeAt(page, '/page-2.html', 'Page 2');
diff --git a/tests/functional/markup.spec.ts b/tests/functional/markup.spec.ts
index d7579ee..779f650 100644
--- a/tests/functional/markup.spec.ts
+++ b/tests/functional/markup.spec.ts
@@ -35,7 +35,7 @@ test.describe('markup', () => {
 		await page.waitForFunction(() => window.data.after !== undefined);
 		expect(await page.evaluate(() => window.data)).toMatchObject({
 			before: 'swup-enabled',
-			leave: 'swup-enabled is-changing is-leaving is-animating',
+			leave: 'swup-enabled is-changing is-animating is-leaving',
 			enter: 'swup-enabled is-changing is-rendering',
 			after: 'swup-enabled'
 		});
@@ -59,7 +59,7 @@ test.describe('markup', () => {
 
 		expect(await page.evaluate(() => window.data)).toMatchObject({
 			before: 'wrapper transition-default',
-			leave: 'wrapper transition-default is-changing is-leaving is-animating',
+			leave: 'wrapper transition-default is-changing is-animating is-leaving',
 			enter: 'wrapper transition-default is-changing is-rendering',
 			after: 'wrapper transition-default'
 		});
diff --git a/tests/functional/native-mode.spec.ts b/tests/functional/native-mode.spec.ts
new file mode 100644
index 0000000..27a25c5
--- /dev/null
+++ b/tests/functional/native-mode.spec.ts
@@ -0,0 +1,27 @@
+import { test } from '@playwright/test';
+
+import { expectSwupAnimationDuration, waitForSwup } from '../support/swup.js';
+import { expectNotToHaveClass, expectToHaveClass } from '../support/commands.js';
+
+test.describe('native mode', () => {
+	test('adds swup-native class if supported', async ({ page }) => {
+		await page.goto('/animation-native.html');
+		await waitForSwup(page);
+		const supported = await page.evaluate(() => !!document.startViewTransition);
+		if (supported) {
+			await expectToHaveClass(page.locator('html'), 'swup-native');
+		} else {
+			await expectNotToHaveClass(page.locator('html'), 'swup-native');
+		}
+	});
+	test('does not add swup-native class if not enabled', async ({ page }) => {
+		await page.goto('/page-1.html');
+		await waitForSwup(page);
+		await expectNotToHaveClass(page.locator('html'), 'swup-native');
+	});
+	test('awaits native view transitions', async ({ page }) => {
+		await page.goto('/animation-native.html');
+		const supported = await page.evaluate(() => !!document.startViewTransition);
+		await expectSwupAnimationDuration(page, { total: supported ? 500 : 800 });
+	});
+});
diff --git a/tests/functional/navigation.spec.ts b/tests/functional/navigation.spec.ts
index daba7b4..2d91ebd 100644
--- a/tests/functional/navigation.spec.ts
+++ b/tests/functional/navigation.spec.ts
@@ -1,7 +1,7 @@
 import { test, expect } from '@playwright/test';
 
-import { clickOnLink, delayRequest, expectToBeAt } from '../support/commands.js';
-import { navigateWithSwup } from '../support/swup.js';
+import { clickOnLink, delayRequest, expectToBeAt, sleep } from '../support/commands.js';
+import { navigateWithSwup, waitForSwup } from '../support/swup.js';
 
 test.describe('navigation', () => {
 	test.beforeEach(async ({ page }) => {
@@ -40,7 +40,7 @@ test.describe('navigation', () => {
 		await expectToBeAt(page, '/page-3.html', 'Page 3');
 	});
 
-	test("ignore multiple rapid clicks on the same link", async ({ page }) => {
+	test('ignores consecutive clicks on the same link', async ({ page }) => {
 		await page.evaluate(() => {
 			window.data = { clickCount: 0 }
 			window._swup.hooks.on('link:click', () => (window.data.clickCount += 1));
@@ -48,4 +48,53 @@ test.describe('navigation', () => {
 		await clickOnLink(page, '/page-2.html', { clickCount: 3 });
 		expect(await page.evaluate(() => window.data.clickCount)).toBe(1);
 	});
+
+	test('handles rapid consecutive navigations', async ({ page }) => {
+		await page.goto('/rapid-navigation/page-1.html');
+		await waitForSwup(page);
+		const expected = [
+			'visit:start',
+			'animation:out:start',
+			'fetch:request',
+			'page:load',
+			'animation:out:await',
+			'animation:out:end',
+			'content:replace',
+			'scroll:top',
+			'content:scroll',
+			'page:view',
+			'animation:in:start',
+			'animation:in:await',
+			'animation:in:end',
+			'visit:transition',
+			'visit:end'
+		];
+		await clickOnLink(page, '/rapid-navigation/page-2.html');
+		await sleep(300);
+		await clickOnLink(page, '/rapid-navigation/page-2.html');
+
+		await page.waitForSelector('html:not([aria-busy=true])');
+		const received = await page.evaluate(() => window.data.received);
+		expect(received).toEqual(expected);
+	});
+
+	test('ignores aborted visits', async ({ page }) => {
+		await page.goto('/rapid-navigation/page-1.html');
+		await waitForSwup(page);
+		await page.evaluate(() => {
+			window._swup.hooks.on('content:replace', (visit) => visit.abort());
+		});
+		const expected = [
+			'visit:start',
+			'animation:out:start',
+			'fetch:request',
+			'page:load',
+			'animation:out:await',
+			'animation:out:end'
+		];
+		await clickOnLink(page, '/rapid-navigation/page-2.html');
+		await sleep(1000); // we have to wait here, since we cannot rely on anything from swup (the visit is being exited)
+		const received = await page.evaluate(() => window.data.received);
+		expect(received).toEqual(expected);
+	});
 });
diff --git a/tests/functional/visit-object.spec.ts b/tests/functional/visit-object.spec.ts
index 7bc1bcd..56c26ff 100644
--- a/tests/functional/visit-object.spec.ts
+++ b/tests/functional/visit-object.spec.ts
@@ -24,6 +24,18 @@ test.describe('visit object', () => {
 		});
 	});
 
+	test('has the next page html', async ({ page }) => {
+		await page.evaluate(() => {
+			window._swup.hooks.before('content:replace', (visit) => {
+				window.data = visit.to.html;
+			});
+		});
+		await navigateWithSwup(page, '/page-2.html');
+		await expectToBeAt(page, '/page-2.html', 'Page 2');
+		await page.waitForSelector('html:not(.is-changing)');
+		expect(await page.evaluate(() => window.data)).toMatch(/<h1>Page 2/i);
+	});
+
 	test('passes along click trigger and event', async ({ page }) => {
 		await page.evaluate(() => {
 			window.data = { el: null, event: null };
@@ -66,6 +78,41 @@ test.describe('visit object', () => {
 		await page.evaluate(() => {
 			window._swup.hooks.on('visit:start', (visit) => visit.animation.animate = false);
 		});
-		await expectSwupAnimationDuration(page, 0);
+		await expectSwupAnimationDuration(page, { out: 0, in: 0 });
+	});
+
+	test('allowings waiting for next page before animating', async ({ page }) => {
+		await page.evaluate(() => {
+			window._swup.hooks.on('visit:start', (visit) => {
+				visit.animation.wait = true;
+			});
+			window._swup.hooks.before('visit:transition', (visit) => {
+				window.data = visit.to.html;
+			});
+		});
+		await navigateWithSwup(page, '/page-2.html');
+		await expectToBeAt(page, '/page-2.html', 'Page 2');
+		expect(await page.evaluate(() => window.data)).toMatch(/<h1>Page 2/i);
+	});
+
+	test('marks native animations', async ({ page }) => {
+		const supported = await page.evaluate(() => !!document.startViewTransition);
+
+		// `visit.animation.native` should always be false when `native` option is false
+		await page.evaluate(() => {
+			window._swup.hooks.on('visit:start', (visit) => (window.data = visit.animation.native));
+		});
+		await navigateWithSwup(page, '/page-2.html');
+		await expectToBeAt(page, '/page-2.html', 'Page 2');
+		expect(await page.evaluate(() => window.data)).toBe(false);
+
+		// `visit.animation.native` should be true if `native` option is true and browser supports it
+		await page.goto('/animation-native.html');
+		await page.evaluate(() => {
+			window._swup.hooks.on('visit:start', (visit) => (window.data = visit.animation.native));
+		});
+		await navigateWithSwup(page, '/page-3.html');
+		await expectToBeAt(page, '/page-3.html', 'Page 3');
+		expect(await page.evaluate(() => window.data)).toBe(supported);
 	});
 });
diff --git a/tests/support/commands.ts b/tests/support/commands.ts
index 4eb67fb..1185a2e 100644
--- a/tests/support/commands.ts
+++ b/tests/support/commands.ts
@@ -52,14 +52,14 @@ export function expectNotToHaveClasses(locator: Locator, classNames: string) {
 	return expectToHaveClasses(locator, classNames, true);
 }
 
-export async function expectPageReload(page: Page, action: (page: Page) => Promise<void> | void, not: boolean = false) {
+export async function expectPageReload(page: Page, action: (page: Page) => Promise<unknown> | unknown, not: boolean = false) {
 	const origin = () => page.evaluate(() => Math.floor(window.performance.timeOrigin));
 	const before = await origin();
 	await action(page);
 	await expect(async () => expect(await origin()).not.toBe(before)).toPass();
 }
 
-export function expectNoPageReload(page: Page, action: (page: Page) => Promise<void> | void) {
+export function expectNoPageReload(page: Page, action: (page: Page) => Promise<unknown> | unknown) {
 	return expectPageReload(page, action, true);
 }
 
@@ -87,3 +87,8 @@ export async function expectScrollPosition(page: Page, expected: number) {
 		async () => expect(await pos()).toEqual(expected)
 	).toPass();
 }
+
+export function expectNumberWithTolerance(value: number, expected: number, tolerance: number = 0.1) {
+	expect(value).toBeGreaterThanOrEqual(expected * (1 - tolerance));
+	expect(value).toBeLessThanOrEqual(expected * (1 + tolerance));
+}
diff --git a/tests/support/swup.ts b/tests/support/swup.ts
index 8a16699..d139c9d 100644
--- a/tests/support/swup.ts
+++ b/tests/support/swup.ts
@@ -1,6 +1,7 @@
 import { expect } from '@playwright/test';
 import type { Page } from '@playwright/test';
 import type Swup from '../../src/Swup.js';
+import { expectNumberWithTolerance } from './commands.js';
 
 declare global {
 	interface Window {
@@ -24,6 +25,7 @@ export async function pushSwupHistoryState(page: Page, url: string, data: Record
 
 export async function expectSwupToHaveCacheEntry(page: Page, url: string) {
 	const entry = await page.evaluate((url) => window._swup.cache.get(url), url);
+	expect(entry).toBeTruthy();
 	expect(entry).toHaveProperty('url', url);
 }
 
@@ -33,16 +35,10 @@ export async function expectSwupToHaveCacheEntries(page: Page, urls: string[]) {
 	}
 }
 
-export async function expectSwupAnimationDuration(page: Page, duration: number) {
-	const tolerance = duration ? 0.25 : 0; // 25% plus/minus
-	const expectedRange: [number, number] = [
-		duration * (1 - tolerance),
-		duration * (1 + tolerance)
-	];
-	let timing = { start: 0, end: 0, outStart: 0, outEnd: 0, inStart: 0, inEnd: 0 };
-
+export async function expectSwupAnimationDuration(page: Page, expected: { total?: number, out?: number, in?: number }) {
 	// Make sure we're ready to animate
 	await waitForSwup(page);
+
 	// Make sure we're not disabling animations
 	await page.emulateMedia({ reducedMotion: null });
 
@@ -63,12 +59,18 @@ export async function expectSwupAnimationDuration(page: Page, duration: number)
 
 	await navigateWithSwup(page, page.url());
 	await page.waitForFunction(() => window.data.end > 0);
-	timing = await page.evaluate(() => window.data);
 
-	const outDuration = timing.outEnd - timing.outStart;
-	const inDuration = timing.inEnd - timing.inStart;
-	expect(outDuration).toBeGreaterThanOrEqual(expectedRange[0]);
-	expect(outDuration).toBeLessThanOrEqual(expectedRange[1]);
-	expect(inDuration).toBeGreaterThanOrEqual(expectedRange[0]);
-	expect(inDuration).toBeLessThanOrEqual(expectedRange[1]);
+	const tolerance = expected ? 0.25 : 0; // 25% plus/minus
+	const timing: { start: 0, end: 0, outStart: 0, outEnd: 0, inStart: 0, inEnd: 0 } = await page.evaluate(() => window.data);
+	const seen = { total: timing.end - timing.start, out: timing.outEnd - timing.outStart, in: timing.inEnd - timing.inStart };
+
+	if (typeof expected.out === 'number') {
+		expectNumberWithTolerance(seen.out, expected.out, tolerance);
+	}
+	if (typeof expected.in === 'number') {
+		expectNumberWithTolerance(seen.in, expected.in, tolerance);
+	}
+	if (typeof expected.total === 'number') {
+		expectNumberWithTolerance(seen.total, expected.total, tolerance);
+	}
 }
diff --git a/tests/unit/cache.test.ts b/tests/unit/cache.test.ts
index 8ba957c..d7dedb2 100644
--- a/tests/unit/cache.test.ts
+++ b/tests/unit/cache.test.ts
@@ -149,9 +149,9 @@ describe('Types', () => {
 		const cache = new Cache(swup);
 
 		// @ts-expect-no-error
-		swup.hooks.on('history:popstate', (visit: Visit, { event: PopStateEvent }) => {});
+		swup.hooks.on('history:popstate', (visit: Visit, { event }: { event: PopStateEvent }) => {});
 		// @ts-expect-no-error
-		await swup.hooks.call('history:popstate', { event: new PopStateEvent('') });
+		await swup.hooks.call('history:popstate', swup.visit, { event: new PopStateEvent('') });
 
 		try {
 			// @ts-expect-error
diff --git a/tests/unit/hooks.test.ts b/tests/unit/hooks.test.ts
index 5d681bc..1023468 100644
--- a/tests/unit/hooks.test.ts
+++ b/tests/unit/hooks.test.ts
@@ -1,7 +1,13 @@
 import { describe, expect, it, vi } from 'vitest';
 import Swup from '../../src/Swup.js';
 import { Hooks, type HookHandler, type HookDefaultHandler } from '../../src/modules/Hooks.js';
-import { type Visit } from '../../src/modules/Visit.js';
+import { type Visit, createVisit } from '../../src/modules/Visit.js';
+
+class SwupWithPublicVisitMethods extends Swup {
+	public createVisit = createVisit;
+}
+
+const swup = new SwupWithPublicVisitMethods();
 
 describe('Hook registry', () => {
 	it('should add handlers', () => {
@@ -37,14 +43,14 @@ describe('Hook registry', () => {
 		swup.hooks.on('enable', handler1);
 		swup.hooks.on('enable', handler2);
 
-		await swup.hooks.call('enable', undefined);
+		await swup.hooks.call('enable', undefined, undefined);
 
 		expect(handler1).toBeCalledTimes(1);
 		expect(handler2).toBeCalledTimes(1);
 
 		swup.hooks.off('enable', handler2);
 
-		await swup.hooks.call('enable', undefined);
+		await swup.hooks.call('enable', undefined, undefined);
 
 		expect(handler1).toBeCalledTimes(2);
 		expect(handler2).toBeCalledTimes(1);
@@ -60,14 +66,14 @@ describe('Hook registry', () => {
 
 		expect(unregister1).toBeTypeOf('function');
 
-		await swup.hooks.call('enable', undefined);
+		await swup.hooks.call('enable', undefined, undefined);
 
 		expect(handler1).toBeCalledTimes(1);
 		expect(handler2).toBeCalledTimes(1);
 
 		unregister2();
 
-		await swup.hooks.call('enable', undefined);
+		await swup.hooks.call('enable', undefined, undefined);
 
 		expect(handler1).toBeCalledTimes(2);
 		expect(handler2).toBeCalledTimes(1);
@@ -79,7 +85,7 @@ describe('Hook registry', () => {
 
 		swup.hooks.on('enable', handler);
 
-		await swup.hooks.call('enable', undefined);
+		await swup.hooks.call('enable', undefined, undefined);
 
 		expect(handler).toBeCalledTimes(1);
 	});
@@ -90,8 +96,8 @@ describe('Hook registry', () => {
 
 		swup.hooks.on('enable', handler, { once: true });
 
-		await swup.hooks.call('enable', undefined, () => {});
-		await swup.hooks.call('enable', undefined, () => {});
+		await swup.hooks.call('enable', undefined, undefined, () => {});
+		await swup.hooks.call('enable', undefined, undefined, () => {});
 
 		expect(handler).toBeCalledTimes(1);
 	});
@@ -102,8 +108,8 @@ describe('Hook registry', () => {
 
 		swup.hooks.once('enable', handler);
 
-		await swup.hooks.call('enable', undefined, () => {});
-		await swup.hooks.call('enable', undefined, () => {});
+		await swup.hooks.call('enable', undefined, undefined, () => {});
+		await swup.hooks.call('enable', undefined, undefined, () => {});
 
 		expect(handler).toBeCalledTimes(1);
 	});
@@ -112,7 +118,7 @@ describe('Hook registry', () => {
 		const swup = new Swup();
 		const handler = vi.fn();
 
-		await swup.hooks.call('enable', undefined, handler);
+		await swup.hooks.call('enable', undefined, undefined, handler);
 
 		expect(handler).toBeCalledTimes(1);
 	});
@@ -140,7 +146,7 @@ describe('Hook registry', () => {
 		swup.hooks.on('disable', handlers.normal, {});
 		swup.hooks.on('disable', handlers.after, {});
 
-		await swup.hooks.call('disable', undefined, handlers.original);
+		await swup.hooks.call('disable', undefined, undefined, handlers.original);
 
 		expect(called).toEqual(['before', 'original', 'normal', 'after']);
 	});
@@ -187,7 +193,7 @@ describe('Hook registry', () => {
 		swup.hooks.on('disable', handlers['8'], { priority: 4 });
 		swup.hooks.on('disable', handlers['7'], { priority: 4 });
 
-		await swup.hooks.call('disable', undefined, handlers['5']);
+		await swup.hooks.call('disable', undefined, undefined, handlers['5']);
 
 		expect(called).toEqual([2, 1, 5, 9, 4, 3, 8, 7]);
 	});
@@ -199,7 +205,7 @@ describe('Hook registry', () => {
 
 		swup.hooks.on('enable', customHandler, { replace: true });
 
-		await swup.hooks.call('enable', undefined, defaultHandler);
+		await swup.hooks.call('enable', undefined, undefined, defaultHandler);
 
 		expect(defaultHandler).toBeCalledTimes(0);
 		expect(customHandler).toBeCalledTimes(1);
@@ -214,7 +220,7 @@ describe('Hook registry', () => {
 		swup.hooks.on('enable', firstHandler, { replace: true });
 		swup.hooks.on('enable', secondHandler, { replace: true });
 
-		await swup.hooks.call('enable', undefined, defaultHandler);
+		await swup.hooks.call('enable', undefined, undefined, defaultHandler);
 
 		expect(defaultHandler).toBeCalledTimes(0);
 		expect(firstHandler).toBeCalledTimes(0);
@@ -230,7 +236,7 @@ describe('Hook registry', () => {
 
 		swup.hooks.on('enable', customHandler, { replace: true });
 
-		await swup.hooks.call('enable', undefined, defaultHandler);
+		await swup.hooks.call('enable', undefined, undefined, defaultHandler);
 
 		expect(customHandler).toBeCalledWith(visit, undefined, defaultHandler);
 		expect(defaultHandler).toBeCalledWith(visit, args);
@@ -243,7 +249,7 @@ describe('Hook registry', () => {
 
 		swup.hooks.on('enable', customHandler, { replace: true });
 
-		const result = await swup.hooks.call('enable', undefined, defaultHandler);
+		const result = await swup.hooks.call('enable', undefined, undefined, defaultHandler);
 
 		expect(result).toEqual('foo');
 	});
@@ -263,7 +269,7 @@ describe('Hook registry', () => {
 		swup.hooks.on('enable', secondHandler, { replace: true });
 		swup.hooks.on('enable', thirdHandler, { replace: true });
 
-		await swup.hooks.call('enable', undefined, defaultHandler);
+		await swup.hooks.call('enable', undefined, undefined, defaultHandler);
 
 		expect(defaultHandler).toBeCalledWith(visit, args1);
 		expect(thirdHandler).toBeCalledWith(visit, undefined, expect.any(Function));
@@ -279,22 +285,55 @@ describe('Hook registry', () => {
 
 		swup.hooks.on('enable', listener);
 
-		await swup.hooks.call('enable', undefined, handler);
+		await swup.hooks.call('enable', undefined, undefined, handler);
 
 		expect(listener).toBeCalledWith(visit, undefined, undefined);
 	});
 
-	it('should trigger event handler with visit and args', async () => {
-		const swup = new Swup();
+	it('should trigger hook handler with visit and args', async () => {
+		const swup = new SwupWithPublicVisitMethods();
 		const handler: HookHandler<'history:popstate'> = vi.fn();
 		const visit = swup.visit;
+		const customVisit = swup.createVisit({ to: '' });
 		const args = { event: new PopStateEvent('') };
 
 		swup.hooks.on('history:popstate', handler);
+
+		// Trigger with specific visit
+		await swup.hooks.call('history:popstate', visit, args);
+		await swup.hooks.call('history:popstate', customVisit, args);
+		// Trigger without specific visit, i.e. with fallback visit
+		await swup.hooks.call('history:popstate', undefined, args);
+
+		expect(handler).toBeCalledTimes(3);
+		expect(handler).toHaveBeenNthCalledWith(1, visit, args, undefined);
+		expect(handler).toHaveBeenNthCalledWith(2, customVisit, args, undefined);
+		expect(handler).toHaveBeenNthCalledWith(3, visit, args, undefined);
+	});
+
+	it('should accept legacy argument position', async () => {
+		const swup = new SwupWithPublicVisitMethods();
+		const handler: HookHandler<'history:popstate'> = vi.fn();
+		const enableHandler: HookHandler<'enable'> = vi.fn();
+		const defaultHandler: HookDefaultHandler<'history:popstate'> = vi.fn();
+		const visit = swup.visit;
+		const args = { event: new PopStateEvent('') };
+
+		swup.hooks.on('history:popstate', handler);
+		swup.hooks.on('enable', enableHandler);
+
+		// Trigger with legacy argument position
+		await swup.hooks.call('history:popstate', args, defaultHandler);
 		await swup.hooks.call('history:popstate', args);
+		await swup.hooks.call('enable', undefined, undefined);
 
-		expect(handler).toBeCalledTimes(1);
-		expect(handler).toBeCalledWith(visit, args, undefined);
+		expect(handler).toBeCalledTimes(2);
+		expect(handler).toHaveBeenNthCalledWith(1, visit, args, undefined);
+		expect(handler).toHaveBeenNthCalledWith(2, visit, args, undefined);
+		expect(enableHandler).toHaveBeenNthCalledWith(1, visit, undefined, undefined);
+
+		expect(defaultHandler).toBeCalledTimes(1);
+		expect(defaultHandler).toHaveBeenNthCalledWith(1, visit, args, undefined);
 	});
 });
 
@@ -303,17 +342,23 @@ describe('Types', () => {
 		const swup = new Swup();
 
 		// @ts-expect-no-error
-		swup.hooks.on('history:popstate', (visit: Visit, { event }: { event: PopStateEvent }) => {});
+		swup.hooks.on(
+			'history:popstate',
+			(visit: Visit, { event }: { event: PopStateEvent }) => {}
+		);
 		// @ts-expect-no-error
-		await swup.hooks.call('history:popstate', { event: new PopStateEvent('') });
+		await swup.hooks.call('history:popstate', undefined, { event: new PopStateEvent('') });
 
 		// @ts-expect-error: first arg must be Visit object
 		swup.hooks.on('history:popstate', ({ event: MouseEvent }) => {});
 		// @ts-expect-error: event arg must be PopStateEvent
 		swup.hooks.on('history:popstate', (visit: Visit, { event }: { event: MouseEvent }) => {});
 		// @ts-expect-error: event arg must be PopStateEvent
-		await swup.hooks.call('history:popstate', { event: new MouseEvent('') });
+		await swup.hooks.call('history:popstate', undefined, { event: new MouseEvent('') });
 		// @ts-expect-error: handler arg must be optional: handler?
-		swup.hooks.replace('enable', (visit: Visit, args: undefined, handler: HookDefaultHandler<'enable'>) => {});
+		swup.hooks.replace(
+			'enable',
+			(visit: Visit, args: undefined, handler: HookDefaultHandler<'enable'>) => {}
+		);
 	});
 });
diff --git a/tests/unit/visit.test.ts b/tests/unit/visit.test.ts
index e2d37b8..0b8f22d 100644
--- a/tests/unit/visit.test.ts
+++ b/tests/unit/visit.test.ts
@@ -18,6 +18,10 @@ describe('Visit', () => {
 		expect(visit).to.be.an('object');
 	});
 
+	it('is a Visit instance', () => {
+		expect(visit).to.be.instanceof(Visit);
+	});
+
 	it('has an id', () => {
 		expect(visit.id).to.be.a('number');
 	});