Repository: swup/swup
Pull request #683 (2023-06-22 19:28:26)

Context object
Pass along a context object with information about the current page load. This context object is available in all hook handlers, as well as in the swup instance itself. Swup, plugins, and handlers can set flags on the context object to enable/disable certain functionality, e.g. let the fragment plugin disable animations and scrolling on the next visit.

## The context object

The context object holds a few properties, e.g. the current and next url, whether to animate this visit, the custom transition, the history action, info about the triggering element/event/popstate, and scroll info.

```js
{
  from: { url: '/home' },
  to: { url: '/about' },
  animate: true,
  transition: 'fade',
  trigger: {
    el: /* <a> element */,
    event: /* MouseEvent */
  },
  history: {
    popstate: false,
    action: 'push',
  },
  scroll: {
    reset: true,
    target: '#footer'
  }
}
```

## Accessing the context object

The context object is available in all hook handlers:

```js
swup.hooks.on('contentReplaced', (context) => {
  console.log('Scrolling to element', context.scroll.target);
});
```

It's also available from inside swup itself, simplifying things by not having to pass around PopStateEvents and other flags:

```js
export const leavePage = async function (this: Swup) {
  if (!this.context.animate) {
    await this.hooks.trigger('animationSkipped');
    return;
  }
}
```

## What to do with this?

Disable animation by setting a flag:

```js
swup.hooks.before('transitionStart', (context) => {
  context.animate = false;
});
```

Disable scrolling by setting a flag:

```js
swup.hooks.before('transitionStart', (context) => {
  context.scroll.reset = false;
});
```

Set a custom transition:

```js
swup.hooks.before('transitionStart', (context) => {
  context.transition = 'slide';
});
```

Get the original trigger event: click, form submit, etc.

```js
swup.hooks.before('transitionStart', (context) => {
  if (context.trigger.event instanceof MouseEvent) {
   console.log('Original click event', context.trigger.event);
  }
});
```

Check if we're on a history visit:

```js
swup.hooks.before('transitionStart', (context) => {
  if (context.history.popstate) {
    console.log('History visit');
  }
});
```

## Plugins

Fragment plugin: Disable animations and scrolling if a fragment visit is detected:

```js
this.swup.hooks.before('transitionStart', (context) => {
  if (isFragmentVisit) {
    context.animate = false;
    context.scroll.reset = false;
  }
});
```

Route plugin: augment from and to with route property:

```js
this.swup.hooks.before('transitionStart', (context) => {
  context.from.route = this.getMatchingRouteName(context.from.url);
  context.to.route = this.getMatchingRouteName(context.to.url);
});
```

## Checks

- [x] The PR is submitted to the `next` 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/cypress/support/commands.js b/cypress/support/commands.js
index 2b54f6b..e062358 100644
--- a/cypress/support/commands.js
+++ b/cypress/support/commands.js
@@ -106,18 +106,25 @@ Cypress.Commands.add('transitionWithExpectedDuration', function (durationInMs, u
 
 	const durationTolerance = 0.25; // 25% plus/minus
 
-	let startOut = 0;
-	let durationOut = 0;
-	let startIn = 0;
-	let durationIn = 0;
+	let startOut = null;
+	let durationOut = null;
+	let startIn = null;
+	let durationIn = null;
 
 	cy.window().then((window) => {
 		this.swup.hooks.on('animationOutStart', () => (startOut = performance.now()));
 		this.swup.hooks.on('animationOutDone', () => (durationOut = performance.now() - startOut));
 		this.swup.hooks.on('animationInStart', () => (startIn = performance.now()));
 		this.swup.hooks.on('animationInDone', () => (durationIn = performance.now() - startIn));
+		this.swup.hooks.on('animationSkipped', () => (durationIn = 0));
+		this.swup.hooks.on('animationSkipped', () => (durationOut = 0));
 		url = url || window.location.href;
-		this.swup.loadPage({ url });
+		this.swup.loadPage(url);
+	});
+
+	cy.window().should(() => {
+		expect(durationIn).to.be.a('number');
+		expect(durationOut).to.be.a('number');
 	});
 
 	cy.window().should(() => {
diff --git a/src/Swup.ts b/src/Swup.ts
index aa35ea5..d5df63b 100644
--- a/src/Swup.ts
+++ b/src/Swup.ts
@@ -22,9 +22,9 @@ import { replaceContent } from './modules/replaceContent.js';
 import { Handler, HookName, Hooks } from './modules/Hooks.js';
 import { use, unuse, findPlugin, Plugin } from './modules/plugins.js';
 import { renderPage } from './modules/renderPage.js';
-import { updateTransition, shouldSkipTransition } from './modules/transitions.js';
 
 import { queryAll } from './utils.js';
+import { Context, createContext } from './modules/Context.js';
 
 export type Transition = {
 	from?: string;
@@ -50,15 +50,13 @@ export type Options = {
 };
 
 export default class Swup {
-	version = version;
-	// variable for anchor to scroll to after render
-	scrollToElement: string | null = null;
+	version: string = version;
 	// variable for save options
 	options: Options;
 	// running plugin instances
 	plugins: Plugin[] = [];
-	// variable for current transition info object
-	transition: Transition = {};
+	// context data
+	context: Context;
 	// cache instance
 	cache: Cache;
 	// hook registry
@@ -75,8 +73,6 @@ export default class Swup {
 	replaceContent = replaceContent;
 	enterPage = enterPage;
 	delegateEvent = delegateEvent;
-	updateTransition = updateTransition;
-	shouldSkipTransition = shouldSkipTransition;
 	getAnimationPromises = getAnimationPromises;
 	fetchPage = fetchPage;
 	getAnchorElement = getAnchorElement;
@@ -86,6 +82,7 @@ export default class Swup {
 	findPlugin = findPlugin;
 	getCurrentUrl = getCurrentUrl;
 	cleanupAnimationClasses = cleanupAnimationClasses;
+	createContext = createContext;
 
 	defaults: Options = {
 		animateHistoryBrowsing: false,
@@ -112,6 +109,7 @@ export default class Swup {
 
 		this.cache = new Cache(this);
 		this.hooks = new Hooks(this);
+		this.context = this.createContext({ to: undefined });
 
 		if (!this.checkRequirements()) {
 			return;
@@ -148,7 +146,7 @@ export default class Swup {
 		updateHistoryRecord();
 
 		// Trigger enabled event
-		await this.hooks.trigger('enabled', undefined, () => {
+		await this.hooks.trigger('enabled', undefined, (ctx) => {
 			// Add swup-enabled class to html tag
 			document.documentElement.classList.add('swup-enabled');
 		});
@@ -236,17 +234,36 @@ export default class Swup {
 	}
 
 	linkClickHandler(event: DelegateEvent<MouseEvent>) {
-		const linkEl = event.delegateTarget;
-		const { href, url, hash } = Location.fromElement(linkEl as HTMLAnchorElement);
+		const el = event.delegateTarget as HTMLAnchorElement;
+		const { href, url, hash } = Location.fromElement(el);
+
+		// Get the transition name, if specified
+		const transition = el.getAttribute('data-swup-transition') || undefined;
+
+		// Get the history action, if specified
+		let historyAction: HistoryAction | undefined;
+		const historyAttr = el.getAttribute('data-swup-history');
+		if (historyAttr && ['push', 'replace'].includes(historyAttr)) {
+			historyAction = historyAttr as HistoryAction;
+		}
 
 		// Exit early if the link should be ignored
-		if (this.shouldIgnoreVisit(href, { el: linkEl, event })) {
+		if (this.shouldIgnoreVisit(href, { el, event })) {
 			return;
 		}
 
+		this.context = this.createContext({
+			to: url,
+			hash,
+			transition,
+			el,
+			event,
+			action: historyAction
+		});
+
 		// Exit early if control key pressed
 		if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
-			this.hooks.trigger('openPageInNewTab', event);
+			this.hooks.trigger('openPageInNewTab');
 			return;
 		}
 
@@ -255,45 +272,34 @@ export default class Swup {
 			return;
 		}
 
-		this.hooks.triggerSync('clickLink', event, () => {
+		this.hooks.triggerSync('clickLink', { event }, () => {
 			event.preventDefault();
 
+			const from = this.context.from?.url;
+
 			// Handle links to the same page and exit early, where applicable
-			if (!url || url === getCurrentUrl()) {
-				this.handleLinkToSamePage(url, hash, event);
+			if (!url || url === from) {
+				this.handleLinkToSamePage(url, hash);
 				return;
 			}
 
 			// Exit early if the resolved path hasn't changed
-			if (this.isSameResolvedUrl(url, getCurrentUrl())) {
+			if (this.isSameResolvedUrl(url, from ?? '')) {
 				return;
 			}
 
-			// Store the element that should be scrolled to after loading the next page
-			this.scrollToElement = hash || null;
-
-			// Get the custom transition name, if present
-			const customTransition = linkEl.getAttribute('data-swup-transition') || undefined;
-
-			// Get the history action, if set
-			let history: HistoryAction | undefined;
-			const historyAttr = linkEl.getAttribute('data-swup-history');
-			if (historyAttr && ['push', 'replace'].includes(historyAttr)) {
-				history = historyAttr as HistoryAction;
-			}
-
 			// Finally, proceed with loading the page
-			this.performPageLoad({ url, customTransition, history });
+			this.performPageLoad(url, { transition, history: historyAction });
 		});
 	}
 
-	async handleLinkToSamePage(url: string, hash: string, event: DelegateEvent<MouseEvent>) {
+	async handleLinkToSamePage(url: string, hash: string) {
 		if (hash) {
-			await this.hooks.trigger('samePageWithHash', event, () => {
+			await this.hooks.trigger('samePageWithHash', undefined, () => {
 				updateHistoryRecord(url + hash);
 			});
 		} else {
-			await this.hooks.trigger('samePage', event);
+			await this.hooks.trigger('samePage');
 		}
 	}
 
@@ -305,6 +311,8 @@ export default class Swup {
 	}
 
 	popStateHandler(event: PopStateEvent) {
+		const href = event.state?.url ?? location.href;
+
 		// Exit early if this event should be ignored
 		if (this.options.skipPopStateHandling(event)) {
 			return;
@@ -315,27 +323,30 @@ export default class Swup {
 			return;
 		}
 
-		const href = event.state?.url ?? location.href;
-
 		// Exit early if the link should be ignored
 		if (this.shouldIgnoreVisit(href, { event })) {
 			return;
 		}
 
 		const { url, hash } = Location.fromUrl(href);
+		const animate = this.options.animateHistoryBrowsing;
+		const resetScroll = this.options.animateHistoryBrowsing;
+		this.context = this.createContext({
+			to: url,
+			hash,
+			event,
+			animate,
+			resetScroll,
+			popstate: true
+		});
 
-		if (hash) {
-			this.scrollToElement = hash;
-		} else {
-			event.preventDefault();
-		}
+		// Does this even do anything?
+		// if (!hash) {
+		// 	event.preventDefault();
+		// }
 
-		this.hooks.triggerSync('popState', event, () => {
-			if (!this.options.animateHistoryBrowsing) {
-				document.documentElement.classList.remove('is-animating');
-				this.cleanupAnimationClasses();
-			}
-			this.performPageLoad({ url, event });
+		this.hooks.triggerSync('popState', undefined, () => {
+			this.performPageLoad(url);
 		});
 	}
 
diff --git a/src/helpers/fetch.ts b/src/helpers/fetch.ts
index 86c6c06..540c981 100755
--- a/src/helpers/fetch.ts
+++ b/src/helpers/fetch.ts
@@ -1,18 +1,19 @@
-import { TransitionOptions } from '../modules/loadPage.js';
 import { Options } from '../Swup.js';
 
+type Headers = Options['requestHeaders'];
+
+export type FetchOptions = {
+	method?: 'GET' | 'POST';
+	headers?: Headers;
+	data?: XMLHttpRequestBodyInit;
+};
+
 export const fetch = (
-	options: TransitionOptions & { headers: Options['requestHeaders'] },
+	url: string,
+	options: FetchOptions = {},
 	callback: (request: XMLHttpRequest) => void
 ): XMLHttpRequest => {
-	const defaults = {
-		url: window.location.pathname + window.location.search,
-		method: 'GET',
-		data: null,
-		headers: {}
-	};
-
-	const { url, method, headers, data } = { ...defaults, ...options };
+	const { method = 'GET', headers = {}, data = null } = options;
 
 	const request = new XMLHttpRequest();
 
diff --git a/src/modules/Context.ts b/src/modules/Context.ts
new file mode 100644
index 0000000..53246d0
--- /dev/null
+++ b/src/modules/Context.ts
@@ -0,0 +1,79 @@
+import Swup from '../Swup.js';
+import { HistoryAction } from './loadPage.js';
+
+export interface Context<TEvent = Event> {
+	from: PageContext;
+	to?: PageContext;
+	animate: boolean;
+	transition?: string;
+	trigger: TriggerContext<TEvent>;
+	history: HistoryContext;
+	scroll: ScrollContext;
+}
+
+export interface PageContext {
+	url: string;
+}
+
+export interface ScrollContext {
+	reset: boolean;
+	target?: string;
+}
+
+export interface TriggerContext<TEvent = Event> {
+	el?: Element;
+	event?: TEvent;
+}
+
+export interface HistoryContext {
+	action: HistoryAction;
+	popstate: boolean;
+	// direction: 'forward' | 'backward' | undefined
+}
+
+export function createContext(
+	this: Swup,
+	{
+		to,
+		from = this.currentPageUrl,
+		hash: target,
+		el,
+		event,
+		animate = true,
+		transition,
+		popstate = false,
+		action = 'push',
+		resetScroll: reset = true
+	}: {
+		to: string | undefined;
+		from?: string;
+		hash?: string;
+		el?: Element;
+		event?: Event;
+		animate?: boolean;
+		transition?: string;
+		popstate?: boolean;
+		action?: HistoryAction;
+		resetScroll?: boolean;
+	}
+): Context {
+	return {
+		from: { url: from },
+		to: to ? { url: to } : undefined,
+		animate,
+		transition,
+		trigger: {
+			el,
+			event
+		},
+		history: {
+			action,
+			popstate
+			// direction: undefined
+		},
+		scroll: {
+			reset,
+			target
+		}
+	};
+}
diff --git a/src/modules/Hooks.ts b/src/modules/Hooks.ts
index bd8c2af..1afbd89 100644
--- a/src/modules/Hooks.ts
+++ b/src/modules/Hooks.ts
@@ -2,6 +2,7 @@ import { DelegateEvent } from 'delegate-it';
 
 import Swup from '../Swup.js';
 import { isPromise, runAsPromise } from '../utils.js';
+import { Context } from './Context.js';
 
 export interface HookDefinitions {
 	animationInDone: undefined;
@@ -9,28 +10,31 @@ export interface HookDefinitions {
 	animationOutDone: undefined;
 	animationOutStart: undefined;
 	animationSkipped: undefined;
-	clickLink: DelegateEvent<MouseEvent>;
-	contentReplaced: PopStateEvent | undefined;
+	clickLink: { event: DelegateEvent<MouseEvent> };
+	contentReplaced: undefined;
 	disabled: undefined;
 	enabled: undefined;
-	openPageInNewTab: DelegateEvent<MouseEvent>;
+	openPageInNewTab: undefined;
 	pageLoaded: undefined;
 	pageRetrievedFromCache: undefined;
-	pageView: PopStateEvent | undefined;
-	popState: PopStateEvent;
-	samePage: DelegateEvent<MouseEvent>;
-	samePageWithHash: DelegateEvent<MouseEvent>;
+	pageView: undefined;
+	popState: { event: PopStateEvent };
+	samePage: undefined;
+	samePageWithHash: undefined;
 	serverError: undefined;
-	transitionStart: PopStateEvent | undefined;
-	transitionEnd: PopStateEvent | undefined;
-	willReplaceContent: PopStateEvent | undefined;
+	transitionStart: undefined;
+	transitionEnd: undefined;
+	willReplaceContent: undefined;
 }
 
-export type HookArgument<T extends HookName> = HookDefinitions[T];
+export type HookData<T extends HookName> = HookDefinitions[T];
 
 export type HookName = keyof HookDefinitions;
 
-export type Handler<T extends HookName> = (data: HookArgument<T>) => Promise<void> | void;
+export type Handler<T extends HookName> = (
+	context: Context,
+	data: HookData<T>
+) => Promise<void> | void;
 
 export type Handlers = {
 	[K in HookName]: Handler<K>[];
@@ -248,13 +252,15 @@ export class Hooks {
 	 */
 	async trigger<T extends HookName>(
 		hook: T,
-		data?: HookArgument<T>,
-		handler?: Function
+		data?: HookData<T>,
+		handler?: Handler<T>
 	): Promise<any[]> {
 		const { before, after, replace } = this.getHandlers(hook);
 		const results = [
 			...(await this.execute(before, data)),
-			...(handler && !replace ? [await runAsPromise(handler, [data], this.swup)] : []),
+			...(handler && !replace
+				? [await runAsPromise(handler, [this.swup.context, data])]
+				: []),
 			...(await this.execute(after, data))
 		];
 		this.dispatchDomEvent(hook);
@@ -269,11 +275,11 @@ export class Hooks {
 	 * @param handler A default implementation of this hook to execute
 	 * @returns An array of all (possibly unresolved) return values of the executed handlers
 	 */
-	triggerSync<T extends HookName>(hook: T, data?: HookArgument<T>, handler?: Function): any[] {
+	triggerSync<T extends HookName>(hook: T, data?: HookData<T>, handler?: Handler<T>): any[] {
 		const { before, after, replace } = this.getHandlers(hook);
 		const results = [
 			...this.executeSync(before, data),
-			...(handler && !replace ? [handler(data)] : []),
+			...(handler && !replace ? [handler(this.swup.context, data as HookData<T>)] : []),
 			...this.executeSync(after, data)
 		];
 		this.dispatchDomEvent(hook);
@@ -287,12 +293,12 @@ export class Hooks {
 	 */
 	async execute<T extends HookName>(
 		registrations: HookRegistration<T>[],
-		data: HookArgument<T>
+		data: HookData<T>
 	): Promise<any> {
 		const results = [];
 		for (const { hook, handler, once } of registrations) {
 			try {
-				results.push(await runAsPromise(handler, [data], this.swup));
+				results.push(await runAsPromise(handler, [this.swup.context, data]));
 			} catch (error) {
 				console.error(error);
 			}
@@ -310,12 +316,12 @@ export class Hooks {
 	 */
 	executeSync<T extends HookName>(
 		registrations: HookRegistration<T>[],
-		data: HookArgument<T>
+		data: HookData<T>
 	): any[] {
 		const results = [];
 		for (const { hook, handler, once } of registrations) {
 			try {
-				const result = handler(data);
+				const result = handler(this.swup.context, data);
 				if (isPromise(result)) {
 					console.warn(
 						`Promise returned from handler for synchronous hook '${hook}'.` +
diff --git a/src/modules/enterPage.ts b/src/modules/enterPage.ts
index 298f90d..0275c0b 100755
--- a/src/modules/enterPage.ts
+++ b/src/modules/enterPage.ts
@@ -1,25 +1,16 @@
 import Swup from '../Swup.js';
-import { PageRenderOptions } from './renderPage.js';
 
-export const enterPage = async function (
-	this: Swup,
-	{ event, skipTransition }: PageRenderOptions = {}
-) {
-	if (skipTransition) {
-		await this.hooks.trigger('transitionEnd', event);
-		this.cleanupAnimationClasses();
-		return;
+export const enterPage = async function (this: Swup) {
+	if (this.context.animate) {
+		const animationPromises = this.getAnimationPromises('in');
+		await this.hooks.trigger('animationInStart', undefined, () => {
+			document.documentElement.classList.remove('is-animating');
+		});
+		await Promise.all(animationPromises);
+		await this.hooks.trigger('animationInDone');
 	}
 
-	const animationPromises = this.getAnimationPromises('in');
-
-	await this.hooks.trigger('animationInStart', undefined, () => {
-		document.documentElement.classList.remove('is-animating');
-	});
-
-	await Promise.all(animationPromises);
-	await this.hooks.trigger('animationInDone');
-
-	await this.hooks.trigger('transitionEnd', event);
+	await this.hooks.trigger('transitionEnd');
 	this.cleanupAnimationClasses();
+	this.context = this.createContext({ to: undefined });
 };
diff --git a/src/modules/fetchPage.ts b/src/modules/fetchPage.ts
index 93b98d9..c366d23 100644
--- a/src/modules/fetchPage.ts
+++ b/src/modules/fetchPage.ts
@@ -1,15 +1,19 @@
 import Swup from '../Swup.js';
 import { Location, fetch } from '../helpers.js';
-import { TransitionOptions } from './loadPage.js';
+import { PageLoadOptions } from './loadPage.js';
 
 export type PageData = {
 	url: string;
 	html: string;
 };
 
-export async function fetchPage(this: Swup, data: TransitionOptions): Promise<PageData> {
+export async function fetchPage(
+	this: Swup,
+	url: string,
+	data: PageLoadOptions = {}
+): Promise<PageData> {
 	const headers = this.options.requestHeaders;
-	const { url: requestURL } = Location.fromUrl(data.url);
+	const { url: requestURL } = Location.fromUrl(url);
 
 	const cachedPage = this.cache.getPage(requestURL);
 	if (cachedPage) {
@@ -18,7 +22,7 @@ export async function fetchPage(this: Swup, data: TransitionOptions): Promise<Pa
 	}
 
 	const page = await new Promise<PageData>((resolve, reject) => {
-		fetch({ ...data, headers }, (response) => {
+		fetch(url, { ...data, headers }, (response) => {
 			const { status, responseText, responseURL } = response;
 
 			if (status === 500) {
diff --git a/src/modules/leavePage.ts b/src/modules/leavePage.ts
index cb39867..c45d2c0 100755
--- a/src/modules/leavePage.ts
+++ b/src/modules/leavePage.ts
@@ -1,22 +1,20 @@
 import Swup from '../Swup.js';
-import { PageRenderOptions } from './renderPage.js';
+import { classify } from '../helpers.js';
 
-export const leavePage = async function (
-	this: Swup,
-	{ event, skipTransition }: PageRenderOptions = {}
-) {
-	const isHistoryVisit = event instanceof PopStateEvent;
-
-	if (skipTransition) {
+export const leavePage = async function (this: Swup) {
+	if (!this.context.animate) {
 		await this.hooks.trigger('animationSkipped');
 		return;
 	}
 
 	await this.hooks.trigger('animationOutStart', undefined, () => {
 		document.documentElement.classList.add('is-changing', 'is-leaving', 'is-animating');
-		if (isHistoryVisit) {
+		if (this.context.history.popstate) {
 			document.documentElement.classList.add('is-popstate');
 		}
+		if (this.context.transition) {
+			document.documentElement.classList.add(`to-${classify(this.context.transition)}`);
+		}
 	});
 
 	const animationPromises = this.getAnimationPromises('out');
diff --git a/src/modules/loadPage.ts b/src/modules/loadPage.ts
index 0288b35..5f129dc 100755
--- a/src/modules/loadPage.ts
+++ b/src/modules/loadPage.ts
@@ -1,62 +1,53 @@
-import {
-	classify,
-	createHistoryRecord,
-	updateHistoryRecord,
-	getCurrentUrl,
-	Location
-} from '../helpers.js';
 import Swup from '../Swup.js';
+import { createHistoryRecord, updateHistoryRecord, getCurrentUrl, Location } from '../helpers.js';
 
 export type HistoryAction = 'push' | 'replace';
 
-export type TransitionOptions = {
-	url: string;
-	customTransition?: string;
-	history?: HistoryAction;
-};
-
 export type PageLoadOptions = {
-	url: string;
-	customTransition?: string;
+	transition?: string;
 	history?: HistoryAction;
-	event?: PopStateEvent;
+	animate?: boolean;
 };
 
-export function loadPage(this: Swup, data: TransitionOptions) {
-	const { url } = data;
-
+export function loadPage(this: Swup, url: string, options: PageLoadOptions = {}) {
 	// Check if the visit should be ignored
 	if (this.shouldIgnoreVisit(url)) {
 		window.location.href = url;
 	} else {
-		this.performPageLoad(data);
+		const { url: to, hash } = Location.fromUrl(url);
+		this.context = this.createContext({ to, hash });
+		this.performPageLoad(to, options);
 	}
 }
 
-export async function performPageLoad(this: Swup, data: PageLoadOptions) {
-	const { url, event, customTransition, history: historyAction = 'push' } = data ?? {};
+export async function performPageLoad(this: Swup, url: string, options: PageLoadOptions = {}) {
+	const { transition, animate, history: historyAction } = options;
 
-	const isHistoryVisit = event instanceof PopStateEvent;
-	const skipTransition = this.shouldSkipTransition({ url, event });
+	if (animate === false) {
+		this.context.animate = false;
+	}
 
-	await this.hooks.trigger('transitionStart', event);
+	if (historyAction) {
+		this.context.history.action = historyAction;
+	}
 
-	// set transition object
-	this.updateTransition(getCurrentUrl(), url, customTransition);
-	if (customTransition != null) {
-		document.documentElement.classList.add(`to-${classify(customTransition)}`);
+	if (!this.context.animate) {
+		document.documentElement.classList.remove('is-animating');
+		this.cleanupAnimationClasses();
+	} else if (transition) {
+		this.context.transition = transition;
 	}
 
-	// start/skip animation
-	const animationPromise = this.leavePage({ event, skipTransition });
+	await this.hooks.trigger('transitionStart');
+
+	const animationPromise = this.leavePage();
 
-	// Load page data
-	const fetchPromise = this.fetchPage(data);
+	const fetchPromise = this.fetchPage(url, options);
 
 	// create history record if this is not a popstate call (with or without anchor)
-	if (!isHistoryVisit) {
-		const historyUrl = url + (this.scrollToElement || '');
-		if (historyAction === 'replace') {
+	if (!this.context.history.popstate) {
+		const historyUrl = url + (this.context.scroll.target || '');
+		if (this.context.history.action === 'replace') {
 			updateHistoryRecord(historyUrl);
 		} else {
 			createHistoryRecord(historyUrl);
@@ -69,7 +60,7 @@ export async function performPageLoad(this: Swup, data: PageLoadOptions) {
 	try {
 		const [page] = await Promise.all([fetchPromise, animationPromise]);
 		const { url: requestedUrl } = Location.fromUrl(url);
-		this.renderPage(requestedUrl, page, { event, skipTransition });
+		this.renderPage(requestedUrl, page);
 	} catch (errorUrl: any) {
 		// Return early if errorUrl is not defined (probably aborted preload request)
 		if (errorUrl === undefined) return;
diff --git a/src/modules/renderPage.ts b/src/modules/renderPage.ts
index 4cde628..266d2ae 100755
--- a/src/modules/renderPage.ts
+++ b/src/modules/renderPage.ts
@@ -1,18 +1,10 @@
-import { Location, updateHistoryRecord, getCurrentUrl } from '../helpers.js';
+import { updateHistoryRecord, getCurrentUrl } from '../helpers.js';
 import Swup from '../Swup.js';
 import { PageData } from './fetchPage.js';
 
-export type PageRenderOptions = {
-	event?: PopStateEvent;
-	skipTransition?: boolean;
-};
+export const renderPage = async function (this: Swup, requestedUrl: string, page: PageData) {
+	const { url } = page;
 
-export const renderPage = async function (
-	this: Swup,
-	requestedUrl: string,
-	page: PageData,
-	{ event, skipTransition }: PageRenderOptions = {}
-) {
 	document.documentElement.classList.remove('is-leaving');
 
 	// do nothing if another page was requested in the meantime
@@ -20,8 +12,6 @@ export const renderPage = async function (
 		return;
 	}
 
-	const { url } = page;
-
 	// update state if the url was redirected
 	if (!this.isSameResolvedUrl(getCurrentUrl(), url)) {
 		this.currentPageUrl = getCurrentUrl();
@@ -29,14 +19,14 @@ export const renderPage = async function (
 	}
 
 	// only add for page loads with transitions
-	if (!skipTransition) {
+	if (this.context.animate) {
 		document.documentElement.classList.add('is-rendering');
 	}
 
-	await this.hooks.trigger('willReplaceContent', event);
+	await this.hooks.trigger('willReplaceContent');
 	await this.replaceContent(page);
-	await this.hooks.trigger('contentReplaced', event);
-	await this.hooks.trigger('pageView', event);
+	await this.hooks.trigger('contentReplaced');
+	await this.hooks.trigger('pageView');
 
 	// empty cache if it's disabled (in case preload plugin filled it)
 	if (!this.options.cache) {
@@ -44,8 +34,5 @@ export const renderPage = async function (
 	}
 
 	// Perform in transition
-	this.enterPage({ event, skipTransition });
-
-	// reset scroll-to element
-	this.scrollToElement = null;
+	this.enterPage();
 };
diff --git a/src/modules/replaceContent.ts b/src/modules/replaceContent.ts
index 83476f7..a7618f5 100755
--- a/src/modules/replaceContent.ts
+++ b/src/modules/replaceContent.ts
@@ -24,7 +24,7 @@ export const replaceContent = function (
 
 	// Update content containers
 	containers.forEach((selector) => {
-		const currentEl = window.document.querySelector(selector);
+		const currentEl = document.querySelector(selector);
 		const incomingEl = doc.querySelector(selector);
 		if (!currentEl) {
 			console.warn(`[swup] Container missing in current document: ${selector}`);
diff --git a/src/modules/transitions.ts b/src/modules/transitions.ts
deleted file mode 100755
index 4894d90..0000000
--- a/src/modules/transitions.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-import Swup from '../Swup.js';
-
-export function updateTransition(this: Swup, from: string, to: string, custom?: string): void {
-	this.transition = { from, to, custom };
-}
-
-export function shouldSkipTransition(this: Swup, { event }: { url?: string; event?: Event }) {
-	const isHistoryVisit = event instanceof PopStateEvent;
-	return !!(isHistoryVisit && !this.options.animateHistoryBrowsing);
-}


Test patch:
diff --git a/cypress/e2e/_plugins.spec.js b/cypress/e2e/_plugins.spec.js
index b38d2e0..f983b8d 100644
--- a/cypress/e2e/_plugins.spec.js
+++ b/cypress/e2e/_plugins.spec.js
@@ -61,7 +61,7 @@ describe('Body Class Plugin', function () {
 
 	it('should update the body class', function () {
 		cy.get('body').should('have.class', 'body-1');
-		this.swup.loadPage({ url: '/plugins/body-class-plugin-2.html' });
+		this.swup.loadPage('/plugins/body-class-plugin-2.html');
 		cy.get('body').should('have.class', 'body-2');
 		cy.get('body').should('not.have.class', 'body-1');
 	});
diff --git a/cypress/e2e/main.spec.js b/cypress/e2e/main.spec.js
index 40bbe74..dba6c44 100644
--- a/cypress/e2e/main.spec.js
+++ b/cypress/e2e/main.spec.js
@@ -36,13 +36,13 @@ describe('Cache', function () {
 	});
 
 	it('should cache pages', function () {
-		this.swup.loadPage({ url: '/page-2.html' });
+		this.swup.loadPage('/page-2.html');
 		cy.shouldBeAtPage('/page-2.html');
 		cy.shouldHaveCacheEntry('/page-2.html');
 	});
 
 	it('should cache pages from absolute URLs', function () {
-		this.swup.loadPage({ url: `${baseUrl}/page-2.html` });
+		this.swup.loadPage(`${baseUrl}/page-2.html`);
 		cy.shouldBeAtPage('/page-2.html');
 		cy.shouldHaveCacheEntry('/page-2.html');
 	});
@@ -287,7 +287,7 @@ describe('Ignoring visits', function () {
 	it('should ignore visits via loadPage', function () {
 		this.swup.options.ignoreVisit = (url) => true;
 		cy.shouldHaveReloadedAfterAction(() => {
-			this.swup.loadPage({ url: '/page-2.html' });
+			this.swup.loadPage('/page-2.html');
 		});
 		cy.shouldBeAtPage('/page-2.html');
 	});
@@ -393,11 +393,11 @@ describe('History', function () {
 
 	it('should replace the current history state via API', function () {
 		cy.window().then(() => {
-			this.swup.loadPage({ url: '/page-2.html' });
+			this.swup.loadPage('/page-2.html');
 		});
 		cy.shouldBeAtPage('/page-2.html');
 		cy.window().then(() => {
-			this.swup.loadPage({ url: '/page-3.html', history: 'replace' });
+			this.swup.loadPage('/page-3.html', { history: 'replace' });
 		});
 		cy.shouldBeAtPage('/page-3.html');
 		cy.window().then((window) => {
@@ -481,8 +481,100 @@ describe('API', function () {
 	});
 
 	it('should transition to pages using swup API', function () {
-		this.swup.loadPage({ url: '/page-2.html' });
+		this.swup.loadPage('/page-2.html');
 		cy.shouldBeAtPage('/page-2.html');
 		cy.shouldHaveH1('Page 2');
 	});
 });
+
+describe('Context', function () {
+	beforeEach(() => {
+		cy.visit('/page-1.html');
+		cy.wrapSwupInstance();
+	});
+
+	it('has current and next url', function () {
+		let from = '';
+		let to = '';
+		this.swup.hooks.before('transitionStart', (ctx) => {
+			from = ctx.from.url;
+			to = ctx.to.url;
+		});
+		cy.triggerClickOnLink('/page-2.html');
+		cy.window().should(() => {
+			expect(from).to.eq('/page-1.html');
+			expect(to).to.eq('/page-2.html');
+		});
+	});
+
+	it('has the correct current url on history visits', function () {
+		let from = '';
+		let to = '';
+		this.swup.hooks.before('transitionStart', (ctx) => {
+			from = ctx.from.url;
+			to = ctx.to.url;
+		});
+		cy.triggerClickOnLink('/page-2.html');
+		cy.shouldBeAtPage('/page-2.html');
+		cy.shouldHaveH1('Page 2');
+		cy.window().then((window) => {
+			window.history.back();
+			cy.shouldBeAtPage('/page-1.html');
+			cy.shouldHaveH1('Page 1');
+			cy.window().should(() => {
+				expect(from).to.eq('/page-2.html');
+				expect(to).to.eq('/page-1.html');
+			});
+		});
+	});
+
+	it('passes along the click trigger', function () {
+		let el = null;
+		this.swup.hooks.before('transitionStart', (context) => {
+			el = context.trigger.el;
+		});
+		cy.triggerClickOnLink('/page-2.html');
+		cy.window().should((win) => {
+			expect(el).to.be.instanceof(win.HTMLAnchorElement)
+		});
+	});
+
+	it('passes along the click event', function () {
+		let event = null;
+		this.swup.hooks.before('transitionStart', (context) => {
+			event = context.trigger.event;
+		});
+		cy.triggerClickOnLink('/page-2.html');
+		cy.window().should((win) => {
+			expect(event).to.be.instanceof(win.MouseEvent)
+		});
+	});
+
+	it('passes along the popstate status', function () {
+		let event = null;
+		let historyVisit = null;
+		this.swup.hooks.before('transitionStart', (context) => {
+			event = context.trigger.event;
+			historyVisit = context.history.popstate;
+		});
+		cy.window().then(() => {
+			this.swup.loadPage('/page-2.html');
+		});
+		cy.shouldBeAtPage('/page-2.html');
+		cy.window().then((window) => {
+			window.history.back();
+		});
+		cy.shouldBeAtPage('/page-1.html');
+		cy.window().should((win) => {
+			expect(event).to.be.instanceof(win.PopStateEvent);
+			expect(historyVisit).to.be.true;
+		});
+	});
+
+	it('should allow disabling animations via context', function () {
+		this.swup.hooks.before('transitionStart', (context) => {
+			context.animate = false;
+		});
+		cy.transitionWithExpectedDuration(0, '/page-2.html');
+	});
+});
diff --git a/src/__test__/index.test.ts b/src/__test__/index.test.ts
index 1343fda..90ef37f 100644
--- a/src/__test__/index.test.ts
+++ b/src/__test__/index.test.ts
@@ -82,7 +82,7 @@ describe('ignoreVisit', () => {
 	it('should be called from loadPage method', () => {
 		const ignoreVisit = vi.fn(() => true);
 		const swup = new Swup({ ignoreVisit });
-		swup.loadPage({ url: '/path/' });
+		swup.loadPage('/path/');
 
 		expect(ignoreVisit.mock.calls).toHaveLength(1);
 	});
diff --git a/src/modules/__test__/hooks.test.ts b/src/modules/__test__/hooks.test.ts
index 518d7c5..e03fea6 100644
--- a/src/modules/__test__/hooks.test.ts
+++ b/src/modules/__test__/hooks.test.ts
@@ -1,6 +1,7 @@
 import { describe, expect, it, vi } from 'vitest';
 import Swup from '../../Swup.js';
 import { Handler, Hooks } from '../Hooks.js';
+import { Context } from '../Context.js';
 
 describe('Hook registry', () => {
 	it('should add custom handlers', () => {
@@ -81,19 +82,6 @@ describe('Hook registry', () => {
 		expect(handler).toBeCalledTimes(1);
 	});
 
-	it('should preserve original handler `this` context', async () => {
-		const swup = new Swup();
-		let thisArg;
-		const handler = vi.fn(function (this: Swup) {
-			thisArg = this;
-		});
-
-		await swup.hooks.trigger('enabled', undefined, handler);
-
-		expect(thisArg).toBeInstanceOf(Swup);
-		expect(thisArg).toBe(swup);
-	});
-
 	it('should allow triggering custom handlers before original handler', async () => {
 		const swup = new Swup();
 
@@ -171,16 +159,17 @@ describe('Hook registry', () => {
 		expect(listener).toBeCalledTimes(1);
 	});
 
-	it('should trigger event handler with event', async () => {
+	it('should trigger event handler with context and args', async () => {
 		const swup = new Swup();
 		const handler: Handler<'popState'> = vi.fn();
-		const event = new PopStateEvent('');
+		const ctx = swup.context;
+		const args = { event: new PopStateEvent('') };
 
 		swup.hooks.on('popState', handler);
-		await swup.hooks.trigger('popState', event);
+		await swup.hooks.trigger('popState', args);
 
 		expect(handler).toBeCalledTimes(1);
-		expect(handler).toBeCalledWith(event);
+		expect(handler).toBeCalledWith(ctx, args);
 	});
 });
 
@@ -213,13 +202,13 @@ describe('Types', () => {
 		const swup = new Swup();
 
 		// @ts-expect-no-error
-		swup.hooks.on('popState', (event: PopStateEvent) => {});
+		swup.hooks.on('popState', (ctx: Context, { event: PopStateEvent }) => {});
 		// @ts-expect-no-error
-		await swup.hooks.trigger('popState', new PopStateEvent(''));
+		await swup.hooks.trigger('popState', { event: new PopStateEvent('') });
 
 		// @ts-expect-error
-		swup.hooks.on('popState', (event: MouseEvent) => {});
+		swup.hooks.on('popState', ({ event: MouseEvent }) => {});
 		// @ts-expect-error
-		await swup.hooks.trigger('popState', new MouseEvent(''));
+		await swup.hooks.trigger('popState', { event: new MouseEvent('') });
 	});
 });