Repository: swup/swup
Pull request #960 (2024-09-26 21:03:54)

Fix hooks replaced once
**Description**

- The `once` option is currently ignored when replacing an internal hook handler
- This fix allows combinations of `once` and `replace`

**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/modules/Hooks.ts b/src/modules/Hooks.ts
index 4c6ef55..e3fb459 100644
--- a/src/modules/Hooks.ts
+++ b/src/modules/Hooks.ts
@@ -77,6 +77,8 @@ export type Handlers = {
 
 export type HookInitOptions = {
 	[K in HookName as K | `${K}.${HookModifier}`]: HookHandler<K>;
+} & {
+	[K in HookName as K | `${K}.${HookModifier}.${HookModifier}`]: HookHandler<K>;
 };
 
 /** Unregister a previously registered hook handler. */
@@ -524,7 +526,7 @@ export class Hooks {
 			handler = [{ id: 0, hook, handler: defaultHandler }];
 			if (replaced) {
 				const index = replace.length - 1;
-				const replacingHandler = replace[index].handler;
+				const { handler: replacingHandler, once } = replace[index];
 				const createDefaultHandler = (index: number): HookDefaultHandler<T> | undefined => {
 					const next = replace[index - 1];
 					if (next) {
@@ -535,9 +537,7 @@ export class Hooks {
 					}
 				};
 				const nestedDefaultHandler = createDefaultHandler(index);
-				handler = [
-					{ id: 0, hook, handler: replacingHandler, defaultHandler: nestedDefaultHandler }
-				];
+				handler = [{ id: 0, hook, once, handler: replacingHandler, defaultHandler: nestedDefaultHandler }]; // prettier-ignore
 			}
 		}
 


Test patch:
diff --git a/tests/unit/hooks.test.ts b/tests/unit/hooks.test.ts
index 33bbd08..1a1c775 100644
--- a/tests/unit/hooks.test.ts
+++ b/tests/unit/hooks.test.ts
@@ -290,6 +290,22 @@ describe('Hook registry', () => {
 		expect(listener).toBeCalledWith(visit, undefined, undefined);
 	});
 
+	it('should allow replacing original handler only once', async () => {
+		const swup = new Swup();
+		const customHandler = vi.fn();
+		const defaultHandler = vi.fn();
+
+		swup.hooks.on('enable', customHandler, { replace: true, once: true });
+
+		await swup.hooks.call('enable', undefined, undefined, defaultHandler);
+		expect(defaultHandler).toBeCalledTimes(0);
+		expect(customHandler).toBeCalledTimes(1);
+
+		await swup.hooks.call('enable', undefined, undefined, defaultHandler);
+		expect(defaultHandler).toBeCalledTimes(1);
+		expect(customHandler).toBeCalledTimes(1);
+	});
+
 	it('should trigger hook handler with visit and args', async () => {
 		const swup = new SwupWithPublicVisitMethods();
 		const handler: HookHandler<'history:popstate'> = vi.fn();
@@ -377,14 +393,16 @@ describe('Hook registry', () => {
 			hooks: {
 				'visit:start': handler,
 				'visit:start.before': handler,
-				'visit:start.once': handler
+				'visit:start.once': handler,
+				'visit:start.once.before': handler
 			}
 		});
 
-		expect(hookSpy).toBeCalledTimes(3);
+		expect(hookSpy).toBeCalledTimes(4);
 		expect(hookSpy).toBeCalledWith('visit:start', handler, {});
 		expect(hookSpy).toBeCalledWith('visit:start', handler, { before: true });
 		expect(hookSpy).toBeCalledWith('visit:start', handler, { once: true });
+		expect(hookSpy).toBeCalledWith('visit:start', handler, { once: true, before: true });
 	});
 });