Repository: swup/swup
Pull request #836 (2023-11-20 23:49:35)

Manage visits via state
**Description**

- Combine/extend @hirasso's 2 PRs: [rapid navigation](https://github.com/swup/swup/pull/825) + [queue navigation](https://github.com/swup/swup/pull/834)
- Add `Visit.state` property and `visit:abort` hook

**Features**

- Convert the `Visit` object to a class
- Pass around the `Visit` object to avoid global mutations
- Add a `state` property to the `Visit` object
  - created → queued → started → leaving → loaded → entering → completed / aborted / failed
- Queue new visit if the current visit is already past the `loaded` state
  - Otherwise, abort the visit and return early in strategic places
  - When aborting only during the `entering` phase, we can have fewer return clauses throughout the code
- Only run hooks if visit is not aborted
- Create `visit:abort` hook to allow undoing dom changes like in `visit:end`
- Always clear `is-leaving` class

**Notes**

- Regarding the naming (`visit.aborted`, `visit:abort`), I asked ChatGPT for a few suggestions and that's what it came up with. I switched to `abort` to see how it feels. I actually quite like it, slightly more than `cancel`. But we can switch back to `cancel` no problem.

**Checks**

- [x] The PR is submitted to the `master` branch
- [x] The code was linted before pushing (`npm run lint`)
- [x] All tests are passing (`npm run test`)
- [x] New or updated tests are included
- [ ] The documentation was updated as required


Patch:
diff --git a/src/Swup.ts b/src/Swup.ts
index 328c0f4..84d00e9 100644
--- a/src/Swup.ts
+++ b/src/Swup.ts
@@ -98,6 +98,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;
@@ -197,7 +199,7 @@ export default class Swup {
 		await nextTick();
 
 		// Trigger enable hook
-		await this.hooks.call('enable', undefined, () => {
+		await this.hooks.call('enable', undefined, undefined, () => {
 			// Add swup-enabled class to html tag
 			document.documentElement.classList.add('swup-enabled');
 		});
@@ -218,7 +220,7 @@ export default class Swup {
 		this.options.plugins.forEach((plugin) => this.unuse(plugin));
 
 		// trigger disable hook
-		await this.hooks.call('disable', undefined, () => {
+		await this.hooks.call('disable', undefined, undefined, () => {
 			// remove swup-enabled class from html tag
 			document.documentElement.classList.remove('swup-enabled');
 		});
@@ -265,11 +267,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 +280,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 +289,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 +313,7 @@ export default class Swup {
 			}
 
 			// Finally, proceed with loading the page
-			this.performNavigation();
+			this.performNavigation(visit);
 		});
 	}
 
@@ -332,37 +332,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/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..4e6fcb9 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,11 +33,12 @@ 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;
@@ -154,6 +155,7 @@ export class Hooks {
 		'scroll:anchor',
 		'visit:start',
 		'visit:transition',
+		'visit:abort',
 		'visit:end'
 	];
 
@@ -333,20 +335,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 +365,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 +417,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 +447,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 +468,6 @@ export class Hooks {
 						`Swup will not wait for it to resolve.`
 				);
 			}
-			if (once) {
-				this.off(hook, handler);
-			}
 		}
 		return results;
 	}
@@ -500,8 +543,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..c0bf79f 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 */
@@ -89,39 +70,99 @@ 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: {
+			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;
+	}
+
+	/** Was the visit aborted? */
+	get aborted(): boolean {
+		return this.state === VisitState.ABORTED;
+	}
+}
+
+/** 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..4a5e6ca 100755
--- a/src/modules/animatePageIn.ts
+++ b/src/modules/animatePageIn.ts
@@ -1,17 +1,20 @@
 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) {
+	if (!visit.animation.animate) return;
+
+	// 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 }) => {
 			if (skip) return;
@@ -21,11 +24,11 @@ export const animatePageIn = async function (this: Swup) {
 
 	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..d9402a9 100755
--- a/src/modules/animatePageOut.ts
+++ b/src/modules/animatePageOut.ts
@@ -1,17 +1,18 @@
 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);
+export const animatePageOut = async function (this: Swup, visit: Visit) {
+	if (!visit.animation.animate) {
+		await this.hooks.call('animation:skip', visit, undefined);
 		return;
 	}
 
-	await this.hooks.call('animation:out:start', undefined, (visit) => {
+	await this.hooks.call('animation:out:start', visit, undefined, (visit) => {
 		this.classes.add('is-changing', 'is-leaving', 'is-animating');
 		if (visit.history.popstate) {
 			this.classes.add('is-popstate');
@@ -21,10 +22,15 @@ export const animatePageOut = async function (this: Swup) {
 		}
 	});
 
-	await this.hooks.call('animation:out:await', { skip: false }, async (visit, { skip }) => {
-		if (skip) return;
-		await this.awaitAnimations({ selector: visit.animation.selector });
-	});
+	await this.hooks.call(
+		'animation:out:await',
+		visit,
+		{ skip: false },
+		async (visit, { skip }) => {
+			if (skip) return;
+			await 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..454d910 100755
--- a/src/modules/navigate.ts
+++ b/src/modules/navigate.ts
@@ -1,7 +1,7 @@
 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';
 
 export type HistoryAction = 'push' | 'replace';
 export type HistoryDirection = 'forwards' | 'backwards';
@@ -43,8 +43,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 +61,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,21 +115,30 @@ 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) => {
-			// Read from cache
-			let cachedPage: PageData | undefined;
-			if (visit.cache.read) {
-				cachedPage = this.cache.get(visit.to.url);
+		const pagePromise = 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;
 			}
+		);
 
-			args.page = cachedPage || (await this.fetchPage(visit.to.url, args.options));
-			args.cache = !!cachedPage;
-
-			return args.page;
-		});
+		// Mark as loaded when finished
+		pagePromise.then(() => visit.advance(VisitState.LOADED));
 
 		// Create/update history record if this is not a popstate call or leads to the same URL
 		if (!visit.history.popstate) {
@@ -138,42 +160,50 @@ export async function performNavigation(
 			visit.to.html = html;
 		}
 
+		// 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', undefined, async (visit) => {
+		await this.hooks.call('visit:transition', visit, undefined, async () => {
 			// Start leave animation
-			const animationPromise = this.animatePageOut();
+			visit.advance(VisitState.LEAVING);
+			const animationPromise = this.animatePageOut(visit);
 
 			// 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;
-			}
-
 			// Render page: replace content and scroll to top/fragment
-			await this.renderPage(page);
+			await this.renderPage(visit, page);
 
 			// Wait for enter animation
-			await this.animatePageIn();
+			visit.advance(VisitState.ENTERING);
+			await this.animatePageIn(visit);
 
 			return true;
 		});
 
-		// 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..ae36ecc 100755
--- a/src/modules/renderPage.ts
+++ b/src/modules/renderPage.ts
@@ -1,11 +1,15 @@
 import { updateHistoryRecord, getCurrentUrl, classify } from '../helpers.js';
 import type Swup from '../Swup.js';
 import type { PageData } from './fetchPage.js';
+import 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> {
+export const renderPage = async function (this: Swup, visit: Visit, page: PageData): Promise<void> {
+	// Check if failed/aborted in the meantime
+	if (visit.done) return;
+
 	const { url, html } = page;
 
 	this.classes.remove('is-leaving');
@@ -14,19 +18,19 @@ 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;
+	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');
@@ -41,10 +45,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/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/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/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/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/support/commands.ts b/tests/support/commands.ts
index 4eb67fb..a22f814 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);
 }
 
diff --git a/tests/support/swup.ts b/tests/support/swup.ts
index 8a16699..168b9c1 100644
--- a/tests/support/swup.ts
+++ b/tests/support/swup.ts
@@ -24,6 +24,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);
 }
 
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');
 	});