Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Events API #8

Merged
merged 14 commits into from
May 9, 2020
126 changes: 126 additions & 0 deletions src/events/Events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { isFunction } from '../support/Utils'

export type EventArgs<T> = T extends any[] ? T : never

export type EventListener<T, K extends keyof T> = (
...args: EventArgs<T[K]>
) => void

export type EventSubscriberArgs<T> = {
[K in keyof T]: { event: K; args: EventArgs<T[K]> }
}[keyof T]

export type EventSubscriber<T> = (arg: EventSubscriberArgs<T>) => void

/**
* Events class for listening to and emitting of events.
* @public
cuebit marked this conversation as resolved.
Show resolved Hide resolved
*/
export class Events<T> {
/**
* The registry for listeners.
*/
protected listeners: { [K in keyof T]?: EventListener<T, K>[] }

/**
* The registry for subscribers.
*/
protected subscribers: EventSubscriber<T>[]

/**
* Creates an Events instance.
*/
constructor() {
this.listeners = Object.create(null)
this.subscribers = []
}

/**
* Register a listener for a given event.
* @returns a function that, when called, will unregister the handler.
cuebit marked this conversation as resolved.
Show resolved Hide resolved
*/
on<K extends keyof T>(event: K, callback: EventListener<T, K>): () => void {
if (!event || !isFunction(callback)) {
return () => {} // Non-blocking noop
cuebit marked this conversation as resolved.
Show resolved Hide resolved
}

;(this.listeners[event] = this.listeners[event]! || []).push(callback)

return () => {
if (callback) {
this.off(event, callback)
;(callback as any) = null // Free up memory
}
}
}

/**
* Register a one-time listener for a given event.
* @returns a function that, when called, will self-execute and unregister the handler.
*/
once<K extends keyof T>(
event: K,
callback: EventListener<T, K>
): EventListener<T, K> {
const fn = (...args: EventArgs<T[K]>) => {
this.off(event, fn)

return callback(...args)
}

this.on(event, fn)

return fn
}

/**
* Unregister a listener for a given event.
*/
off<K extends keyof T>(event: K, callback: EventListener<T, K>): void {
const stack = this.listeners[event]

if (!stack) {
return
}

const i = stack.indexOf(callback)

i > -1 && stack.splice(i, 1)

stack.length === 0 && delete this.listeners[event]
}

/**
* Register a handler for wildcard event subscriber.
* @returns a function that, when called, will unregister the handler.
*/
subscribe(callback: EventSubscriber<T>): () => void {
this.subscribers.push(callback)

return () => {
const i = this.subscribers.indexOf(callback)

i > -1 && this.subscribers.splice(i, 1)
}
}

/**
* Call all handlers for a given event with the specified args(?).
*/
emit<K extends keyof T>(event: K, ...args: EventArgs<T[K]>): void {
const stack = this.listeners[event]

if (stack) {
stack.slice().forEach((listener) => listener(...args))
}

this.subscribers.slice().forEach((sub) => sub({ event, args }))
}

/**
* Remove all listeners for a given event.
*/
protected removeAllListeners<K extends keyof T>(event: K): void {
event && this.listeners[event] && delete this.listeners[event]
}
}
162 changes: 162 additions & 0 deletions test/unit/events/Events.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { Events } from '@/events/Events'

describe('unit/events/Events', () => {
interface TEvents {
test: [boolean]
trial: []
}

it('can register event listeners', () => {
const events = new Events<TEvents>()

const spy = jest.fn()

events.on('test', spy)

expect(events['listeners']).toHaveProperty('test')
expect(events['listeners'].test).toHaveLength(1)
expect(events['listeners'].test).toEqual([spy])
})

it('can ignore empty event names', () => {
const events = new Events<TEvents>()

;[0, '', null, undefined].forEach((e) => {
events.on(e as any, () => {})
})

expect(events['listeners']).toEqual({})
})

it('can ignore non-function handlers', () => {
const events = new Events<TEvents>()

;[0, '', null, undefined].forEach((e) => {
const cb = events.on('test', e as any)
cb()
})

expect(events['listeners']).toEqual({})
})

it('can emit events', () => {
const events = new Events<TEvents>()

const spy = jest.fn()

events.on('test', spy)
events.emit('test', true)

events.off('test', spy)
events.emit('test', false)

expect(spy).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenLastCalledWith(true)
expect(events['listeners']).toEqual({})
})

it('can noop when removing unknown listeners', () => {
const events = new Events<TEvents>()

const spy1 = jest.fn()
const spy2 = jest.fn()

expect(events['listeners'].test).toBeUndefined()

events.off('test', spy1)

expect(events['listeners'].test).toBeUndefined()

events.on('test', spy2)
events.off('test', spy1)

expect(events['listeners'].test).toEqual([spy2])
})

it('can unregister itself', () => {
const events = new Events<TEvents>()

const spy = jest.fn()

events.on('test', spy)
const unsub = events.on('test', spy)

expect(events['listeners'].test).toHaveLength(2)

unsub()
unsub()

expect(events['listeners'].test).toHaveLength(1)
expect(events['listeners'].test).toEqual([spy])
})

it('can register one-time listeners', () => {
const events = new Events<TEvents>()

const spy1 = jest.fn()
const spy2 = jest.fn()

events.once('test', spy1)
events.on('test', spy2)

expect(events['listeners'].test).toHaveLength(2)

events.emit('test', true)
events.emit('test', false)

expect(events['listeners'].test).toHaveLength(1)
expect(spy1).toHaveBeenCalledTimes(1)
expect(spy1).toHaveBeenCalledWith(true)
expect(spy2).toHaveBeenCalledTimes(2)
expect(spy2).toHaveBeenLastCalledWith(false)
})

it('can emit events to subscribers', () => {
const events = new Events<TEvents>()

const spy = jest.fn()

const unsub = events.subscribe(spy)

events.emit('test', true)
unsub()
events.emit('trial')

expect(events['subscribers']).toEqual([])
expect(spy).toHaveBeenCalledTimes(1)
expect(spy).toHaveBeenCalledWith({ event: 'test', args: [true] })
})

it('can forward events within subscribers', () => {
const events1 = new Events<TEvents>()
const events2 = new Events<Pick<TEvents, 'test'>>()

const spy = jest.fn()

events2.subscribe(({ event, args }) => {
events1.emit(event, ...args)
})

events1.on('test', spy)
events2.emit('test', true)

expect(spy).toHaveBeenLastCalledWith(true)
})

it('can remove all event listeners', () => {
const events = new Events<TEvents>()

const spy = jest.fn()

events.on('test', spy)
events.on('trial', spy)
events.on('test', spy)

expect(events['listeners'].test).toHaveLength(2)

events['removeAllListeners']('test')

expect(events['listeners'].test).toBeUndefined()
expect(events['listeners'].trial).toHaveLength(1)
})
})