Repository: sindresorhus/emittery (js)

Issue:
once: support filter predicate
Sometimes `once()` is very convenient but it can't be used when wanting to wait for the first event that meets some predicate (e.g. an event from a websocket with not only a certain name but where the message payload also provides some specific property). I'd like a way to pass in a filter predicate to `once()` so that I can still use it and rather than using `on()` and manually unsubscribing once the desired event is received.

Pull request description:
Add support for filter predicate in once() (#122)
Add support for filter predicate in once()

This change extends `once(eventNames, predicate)` so you can pass an optional filter function. When provided, `once` will:

• ignore any events for which `predicate(data)` returns false  
• unsubscribe and resolve on the first event where `predicate(data)` returns true  

If no predicate is passed, `once` behaves exactly as before. The predicate is validated up-front and will throw a `TypeError` if it’s not a function. Four new tests cover:

• resolving only on the first matching event  
• throwing on invalid predicate types 
• using multiple event names  
• unsubscribing via `promise.off()` before a match  

Closes #122

Patch:
diff --git a/index.d.ts b/index.d.ts
index c2ca0d1..9eb96a9 100644
--- a/index.d.ts
+++ b/index.d.ts
@@ -462,10 +462,12 @@ export default class Emittery<
 	): void;
 
 	/**
-	Subscribe to one or more events only once. It will be unsubscribed after the first
-	event.
+	Subscribe to one or more events only once. It will be unsubscribed after the first event that matches the predicate (if provided).
 
-	@returns The promise of event data when `eventName` is emitted. This promise is extended with an `off` method.
+	@param eventName - The event name(s) to subscribe to.
+	@param predicate - Optional predicate function to filter event data. The event will only be emitted if the predicate returns true.
+
+	@returns The promise of event data when `eventName` is emitted and predicate matches (if provided). This promise is extended with an `off` method.
 
 	@example
 	```
@@ -482,11 +484,19 @@ export default class Emittery<
 		console.log(data);
 	});
 
+	// With predicate
+	emitter.once('data', data => data.ok === true).then(data => {
+		console.log(data);
+		//=> {ok: true, value: 42}
+	});
+
 	emitter.emit('🦄', '🌈'); // Logs `🌈` twice
 	emitter.emit('🐶', '🍖'); // Nothing happens
+	emitter.emit('data', {ok: false}); // Nothing happens
+	emitter.emit('data', {ok: true, value: 42}); // Logs {ok: true, value: 42}
 	```
 	*/
-	once<Name extends keyof AllEventData>(eventName: Name | readonly Name[]): EmitteryOncePromise<AllEventData[Name]>;
+	once<Name extends keyof AllEventData>(eventName: Name | readonly Name[], predicate?: (eventData: AllEventData[Name]) => boolean): EmitteryOncePromise<AllEventData[Name]>;
 
 	/**
 	Trigger an event asynchronously, optionally with some data. Listeners are called in the order they were added, but executed concurrently.
diff --git a/index.js b/index.js
index e61ca0a..5f4005a 100644
--- a/index.js
+++ b/index.js
@@ -334,11 +334,19 @@ export default class Emittery {
 		}
 	}
 
-	once(eventNames) {
+	once(eventNames, predicate) {
+		if (predicate !== undefined && typeof predicate !== 'function') {
+			throw new TypeError('predicate must be a function');
+		}
+
 		let off_;
 
 		const promise = new Promise(resolve => {
 			off_ = this.on(eventNames, data => {
+				if (predicate && !predicate(data)) {
+					return;
+				}
+
 				off_();
 				resolve(data);
 			});
diff --git a/readme.md b/readme.md
index 5e596f4..657ac04 100644
--- a/readme.md
+++ b/readme.md
@@ -297,11 +297,11 @@ await emitter.emit('🦊', 'c'); // Nothing happens
 
 ##### listener(data)
 
-#### once(eventName | eventName[])
+#### once(eventName | eventName[], predicate?)
 
-Subscribe to one or more events only once. It will be unsubscribed after the first event.
+Subscribe to one or more events only once. It will be unsubscribed after the first event that matches the predicate (if provided).
 
-Returns a promise for the event data when `eventName` is emitted. This promise is extended with an `off` method.
+Returns a promise for the event data when `eventName` is emitted and predicate matches (if provided). This promise is extended with an `off` method.
 
 ```js
 import Emittery from 'emittery';
@@ -317,8 +317,16 @@ emitter.once(['🦄', '🐶']).then(data => {
 	console.log(data);
 });
 
+// With predicate
+emitter.once('data', data => data.ok === true).then(data => {
+	console.log(data);
+	//=> {ok: true, value: 42}
+});
+
 emitter.emit('🦄', '🌈'); // Log => '🌈' x2
 emitter.emit('🐶', '🍖'); // Nothing happens
+emitter.emit('data', {ok: false}); // Nothing happens
+emitter.emit('data', {ok: true, value: 42}); // Log => {ok: true, value: 42}
 ```
 
 #### events(eventName)


Test patch:
diff --git a/test/index.js b/test/index.js
index bd928df..1645044 100644
--- a/test/index.js
+++ b/test/index.js
@@ -432,6 +432,59 @@ test('once() - returns a promise with an unsubscribe method', async t => {
 	t.pass();
 });
 
+test('once() - supports filter predicate', async t => {
+	const emitter = new Emittery();
+
+	const oncePromise = emitter.once('data', data => data.ok === true);
+	await emitter.emit('data', {ok: false, foo: 'bar'});
+
+	const payload = {ok: true, value: 42};
+
+	await emitter.emit('data', payload);
+	await emitter.emit('data', {ok: true, other: 'value'});
+
+	t.is(await oncePromise, payload);
+});
+
+test('once() - filter predicate must be a function', t => {
+	const emitter = new Emittery();
+	t.throws(
+		() => emitter.once('data', 'not a function'),
+		{
+			instanceOf: TypeError,
+			message: 'predicate must be a function',
+		},
+	);
+});
+
+test('once() - filter predicate with multiple event names', async t => {
+	const emitter = new Emittery();
+	const payload = {ok: true, value: 42};
+
+	const oncePromise = emitter.once(['data1', 'data2'], data => data.ok === true);
+	await emitter.emit('data1', {ok: false});
+	await emitter.emit('data2', payload);
+
+	t.is(await oncePromise, payload);
+});
+
+test('once() - filter predicate can be unsubscribed', async t => {
+	const emitter = new Emittery();
+	const oncePromise = emitter.once('data', data => data.ok === true);
+
+	oncePromise.off();
+	await emitter.emit('data', {ok: true});
+
+	const testPromise = Promise.race([
+		oncePromise,
+		new Promise(resolve => {
+			setTimeout(() => resolve('timeout'), 100);
+		}),
+	]);
+
+	t.is(await testPromise, 'timeout');
+});
+
 test('emit() - one event', async t => {
 	const emitter = new Emittery();
 	const eventFixture = {foo: true};