Repository: swup/swup
Pull request #933 (2024-07-23 11:39:10)

Register hooks from constructor options
**Description**

- Add new `hooks` options for registering hook handlers when creating swup
- Accept hook modifiers à la Alpine/Vue: `content:replace.before`

**Example**

```js
const hooks = {
  'animation:out:await.replace': () => awaitAnimation(),
  'content:replace.before': () => updateBodyClass()
  'content:replace': () => updateBodyClass(),
  'page:view': () => trackPageView()
}

const swup = new Swup({ hooks })
```

**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 8d5d769..7fb76f0 100644
--- a/src/Swup.ts
+++ b/src/Swup.ts
@@ -8,7 +8,7 @@ import { type DelegateEventUnsubscribe } from './helpers/delegateEvent.js';
 import { Cache } from './modules/Cache.js';
 import { Classes } from './modules/Classes.js';
 import { type Visit, createVisit } from './modules/Visit.js';
-import { Hooks } from './modules/Hooks.js';
+import { Hooks, type HookName, type HookInitOptions } from './modules/Hooks.js';
 import { getAnchorElement } from './modules/getAnchorElement.js';
 import { awaitAnimations } from './modules/awaitAnimations.js';
 import { navigate, performNavigation, type NavigationToSelfAction } from './modules/navigate.js';
@@ -43,6 +43,8 @@ export type Options = {
 	linkToSelf: NavigationToSelfAction;
 	/** Enable native animations using the View Transitions API. */
 	native: boolean;
+	/** Hook handlers to register. */
+	hooks: Partial<HookInitOptions>;
 	/** Plugins to register on startup. */
 	plugins: Plugin[];
 	/** Custom headers sent along with fetch requests. */
@@ -61,6 +63,7 @@ const defaults: Options = {
 	animationScope: 'html',
 	cache: true,
 	containers: ['#swup'],
+	hooks: {},
 	ignoreVisit: (url, { el } = {}) => !!el?.closest('[data-no-swup]'),
 	linkSelector: 'a[href]',
 	linkToSelf: 'scroll',
@@ -188,6 +191,14 @@ export default class Swup {
 		// Mount plugins
 		this.options.plugins.forEach((plugin) => this.use(plugin));
 
+		// Install user hooks
+		for (const [key, handler] of Object.entries(this.options.hooks)) {
+			// Build hook options from modifier suffix: 'content:replace.before' => { before: true }
+			const [hook, modifiers] = this.hooks.parseName(key as HookName);
+			// @ts-expect-error: object.entries() does not preserve key/value types
+			this.hooks.on(hook, handler, modifiers);
+		}
+
 		// Create initial history record
 		if ((window.history.state as HistoryState)?.source !== 'swup') {
 			updateHistoryRecord(null, { index: this.currentHistoryIndex });
diff --git a/src/modules/Hooks.ts b/src/modules/Hooks.ts
index 36af2bf..4c6ef55 100644
--- a/src/modules/Hooks.ts
+++ b/src/modules/Hooks.ts
@@ -49,6 +49,10 @@ export type HookArguments<T extends HookName> = HookDefinitions[T];
 
 export type HookName = keyof HookDefinitions;
 
+export type HookNameWithModifier = `${HookName}.${HookModifier}`;
+
+type HookModifier = 'once' | 'before' | 'replace';
+
 /** A generic hook handler. */
 export type HookHandler<T extends HookName> = (
 	/** Context about the current visit. */
@@ -71,6 +75,10 @@ export type Handlers = {
 	[K in HookName]: HookHandler<K>[];
 };
 
+export type HookInitOptions = {
+	[K in HookName as K | `${K}.${HookModifier}`]: HookHandler<K>;
+};
+
 /** Unregister a previously registered hook handler. */
 export type HookUnregister = () => void;
 
@@ -570,4 +578,14 @@ export class Hooks {
 			new CustomEvent<HookEventDetail>(`swup:${hook}`, { detail, bubbles: true })
 		);
 	}
+
+	/**
+	 * Parse a hook name into the name and any modifiers.
+	 * @param hook Name of the hook.
+	 */
+	parseName(hook: HookName | HookNameWithModifier): [HookName, Partial<HookOptions>] {
+		const [name, ...modifiers] = hook.split('.');
+		const options = modifiers.reduce((acc, mod) => ({ ...acc, [mod]: true }), {});
+		return [name as HookName, options];
+	}
 }


Test patch:
diff --git a/tests/unit/hooks.test.ts b/tests/unit/hooks.test.ts
index 3da6a0d..33bbd08 100644
--- a/tests/unit/hooks.test.ts
+++ b/tests/unit/hooks.test.ts
@@ -357,6 +357,35 @@ describe('Hook registry', () => {
 		await expect(() => swup.hooks.call('enable', undefined, undefined, handlerWithError)).rejects.toThrow(/^UserError$/);
 		expect(handlerWithError).toBeCalledTimes(2);
 	});
+
+	it('should register hook handlers from options', async function () {
+		const hookSpy = vi.spyOn(Hooks.prototype, 'on');
+		const handler = vi.fn();
+		const swup = new Swup({ hooks: { 'visit:start': handler } });
+
+		await swup.hooks.call('visit:start', undefined, undefined);
+
+		expect(hookSpy).toBeCalledTimes(1);
+		expect(hookSpy).toBeCalledWith('visit:start', handler, {});
+		expect(handler).toBeCalledTimes(1);
+	});
+
+	it('should accept hook registration modifiers from options', async function () {
+		const hookSpy = vi.spyOn(Hooks.prototype, 'on');
+		const handler = vi.fn();
+		const swup = new Swup({
+			hooks: {
+				'visit:start': handler,
+				'visit:start.before': handler,
+				'visit:start.once': handler
+			}
+		});
+
+		expect(hookSpy).toBeCalledTimes(3);
+		expect(hookSpy).toBeCalledWith('visit:start', handler, {});
+		expect(hookSpy).toBeCalledWith('visit:start', handler, { before: true });
+		expect(hookSpy).toBeCalledWith('visit:start', handler, { once: true });
+	});
 });
 
 describe('Types', () => {