Skip to content

Commit

Permalink
feat: Events API (#8)
Browse files Browse the repository at this point in the history
  • Loading branch information
cuebit authored May 9, 2020
1 parent cc98785 commit 465c5d5
Show file tree
Hide file tree
Showing 2 changed files with 292 additions and 0 deletions.
130 changes: 130 additions & 0 deletions src/events/Events.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
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
*/
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.
*/
on<K extends keyof T>(event: K, callback: EventListener<T, K>): () => void {
if (!event || !isFunction(callback)) {
return () => {} // Non-blocking noop.
}

;(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)
})
})

0 comments on commit 465c5d5

Please sign in to comment.