Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -6,33 +6,62 @@ import { useEffect, useRef, useState } from 'react';
* @usage low
*
* @template Events - The type of events and their data
* @returns {EventEmitterApi<Events>} - Object containing event emitter methods and hook
* @returns {EventEmitterApi<Events>} - Object containing event emitter methods and hooks
*
* @example
* const { push, subscribe, unsubscribe, useSubscribe } = createEventEmitter<{ foo: number }>();
* const { push, subscribe, unsubscribe, once, reset, useSubscribe, useSubscribeEffect } =
* createEventEmitter<{ 'user:login': { id: string } }>();
*
* const unsubscribe = subscribe('user:login', (user) => console.log(user.id));
* push('user:login', { id: '1' });
*
* @warning - A listener that throws does not stop the rest, its error is rethrown asynchronously, so a try/catch around push will not catch it. Calling reset without an event also removes the subscriptions of mounted components, and they do not resubscribe on their own, so prefer resetting a specific event.
*/
export const createEventEmitter = () => {
const listeners = new Map();
const push = (event, data) => {
const eventListeners = listeners.get(event);
eventListeners?.forEach((listener) => listener(data));
if (!eventListeners) return;
const eventListenersSnapshot = [...eventListeners];
for (const listener of eventListenersSnapshot) {
try {
listener(data);
} catch (error) {
queueMicrotask(() => {
throw error;
});
}
}
};
const unsubscribe = (event, listener) => {
const eventKey = event;
const eventListeners = listeners.get(eventKey);
const eventListeners = listeners.get(event);
if (!eventListeners) return;
eventListeners.delete(listener);
if (!eventListeners.size) listeners.delete(eventKey);
if (!eventListeners.delete(listener)) {
for (const candidate of eventListeners) {
if (candidate.listener === listener) {
eventListeners.delete(candidate);
break;
}
}
}
if (!eventListeners.size) listeners.delete(event);
};
const subscribe = (event, listener) => {
const eventKey = event;
if (!listeners.has(eventKey)) listeners.set(eventKey, new Set());
if (!listeners.has(event)) listeners.set(event, new Set());
const eventListeners = listeners.get(event);
eventListeners.add(listener);
return () => {
unsubscribe(event, listener);
};
};
const once = (event, listener) => {
const wrapper = (data) => {
unsubscribe(event, wrapper);
listener(data);
};
wrapper.listener = listener;
return subscribe(event, wrapper);
};
const useSubscribe = (event, listener) => {
const [data, setData] = useState(undefined);
const listenerRef = useRef(listener);
Expand All @@ -49,10 +78,33 @@ export const createEventEmitter = () => {
}, [event]);
return data;
};
const useSubscribeEffect = (event, listener) => {
const listenerRef = useRef(listener);
listenerRef.current = listener;
useEffect(() => {
const onSubscribe = (data) => {
listenerRef.current?.(data);
};
const unsubscribe = subscribe(event, onSubscribe);
return () => {
unsubscribe();
};
}, [event]);
};
const reset = (event) => {
if (event !== undefined) {
listeners.delete(event);
} else {
listeners.clear();
}
};
return {
push,
subscribe,
unsubscribe,
useSubscribe
useSubscribe,
reset,
useSubscribeEffect,
once
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { beforeEach, expect, it, vi } from 'vitest';
import { createEventEmitter } from './createEventEmitter';

interface TestEvents {
'cart:updated': { total: number };
'user:login': { id: string; timestamp: number };
}

Expand All @@ -18,6 +19,9 @@ it('Should create event emitter', () => {
expect(eventEmitter.subscribe).toBeTypeOf('function');
expect(eventEmitter.unsubscribe).toBeTypeOf('function');
expect(eventEmitter.useSubscribe).toBeTypeOf('function');
expect(eventEmitter.useSubscribeEffect).toBeTypeOf('function');
expect(eventEmitter.once).toBeTypeOf('function');
expect(eventEmitter.reset).toBeTypeOf('function');
});

it('Should push events', () => {
Expand Down Expand Up @@ -117,3 +121,208 @@ it('Should unsubscribe on unmount', () => {

expect(callback).toHaveBeenCalledOnce();
});

it('Should continue dispatch when listener throws', () => {
const queueMicrotaskSpy = vi.spyOn(globalThis, 'queueMicrotask').mockImplementation(() => {});
const throwingCallback = vi.fn(() => {
throw new Error('listener error');
});
const callback = vi.fn();

eventEmitter.subscribe('user:login', throwingCallback);
eventEmitter.subscribe('user:login', callback);

const data = { id: '1', timestamp: Date.now() };

expect(() => eventEmitter.push('user:login', data)).not.toThrow();

expect(throwingCallback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith(data);
expect(queueMicrotaskSpy).toHaveBeenCalledOnce();
});

it('Should report listener error asynchronously', () => {
const queueMicrotaskSpy = vi.spyOn(globalThis, 'queueMicrotask').mockImplementation(() => {});
const error = new Error('listener error');

eventEmitter.subscribe('user:login', () => {
throw error;
});
eventEmitter.push('user:login', { id: '1', timestamp: Date.now() });

const [report] = queueMicrotaskSpy.mock.calls[0];

expect(report).toThrow(error);
});

it('Should not deliver current event to listener subscribed during dispatch', () => {
const lateCallback = vi.fn();

eventEmitter.subscribe('user:login', () => eventEmitter.subscribe('user:login', lateCallback));

const data = { id: '1', timestamp: Date.now() };
eventEmitter.push('user:login', data);

expect(lateCallback).not.toHaveBeenCalled();

eventEmitter.push('user:login', data);

expect(lateCallback).toHaveBeenCalledOnce();
});

it('Should deliver current event to listener unsubscribed during dispatch', () => {
const callback = vi.fn();

eventEmitter.subscribe('user:login', () => eventEmitter.unsubscribe('user:login', callback));
eventEmitter.subscribe('user:login', callback);

const data = { id: '1', timestamp: Date.now() };
eventEmitter.push('user:login', data);

expect(callback).toHaveBeenCalledWith(data);

eventEmitter.push('user:login', { id: '2', timestamp: Date.now() });

expect(callback).toHaveBeenCalledOnce();
});

it('Should call once listener one time', () => {
const callback = vi.fn();
const data = { id: '1', timestamp: Date.now() };

eventEmitter.once('user:login', callback);

eventEmitter.push('user:login', data);
eventEmitter.push('user:login', { id: '2', timestamp: Date.now() });

expect(callback).toHaveBeenCalledOnce();
expect(callback).toHaveBeenCalledWith(data);
});

it('Should unsubscribe once listener by returned function', () => {
const callback = vi.fn();
const unsubscribe = eventEmitter.once('user:login', callback);

unsubscribe();

eventEmitter.push('user:login', { id: '1', timestamp: Date.now() });

expect(callback).not.toHaveBeenCalled();
});

it('Should unsubscribe once listener by original reference', () => {
const callback = vi.fn();

eventEmitter.once('user:login', callback);
eventEmitter.unsubscribe('user:login', callback);

eventEmitter.push('user:login', { id: '1', timestamp: Date.now() });

expect(callback).not.toHaveBeenCalled();
});

it('Should keep once listener unsubscribed when it throws', () => {
vi.spyOn(globalThis, 'queueMicrotask').mockImplementation(() => {});
const callback = vi.fn(() => {
throw new Error('listener error');
});

eventEmitter.once('user:login', callback);

eventEmitter.push('user:login', { id: '1', timestamp: Date.now() });
eventEmitter.push('user:login', { id: '2', timestamp: Date.now() });

expect(callback).toHaveBeenCalledOnce();
});

it('Should reset listeners for specific event', () => {
const loginCallback = vi.fn();
const cartCallback = vi.fn();

eventEmitter.subscribe('user:login', loginCallback);
eventEmitter.subscribe('cart:updated', cartCallback);

eventEmitter.reset('user:login');

eventEmitter.push('user:login', { id: '1', timestamp: Date.now() });
eventEmitter.push('cart:updated', { total: 100 });

expect(loginCallback).not.toHaveBeenCalled();
expect(cartCallback).toHaveBeenCalledOnce();
});

it('Should reset all listeners when called without arguments', () => {
const loginCallback = vi.fn();
const cartCallback = vi.fn();

eventEmitter.subscribe('user:login', loginCallback);
eventEmitter.subscribe('cart:updated', cartCallback);

eventEmitter.reset();

eventEmitter.push('user:login', { id: '1', timestamp: Date.now() });
eventEmitter.push('cart:updated', { total: 100 });

expect(loginCallback).not.toHaveBeenCalled();
expect(cartCallback).not.toHaveBeenCalled();
});

it('Should reset only falsy event key', () => {
const emitter = createEventEmitter<{ '': string; keep: string }>();
const emptyKeyCallback = vi.fn();
const keepCallback = vi.fn();

emitter.subscribe('', emptyKeyCallback);
emitter.subscribe('keep', keepCallback);

emitter.reset('');

emitter.push('', 'first');
emitter.push('keep', 'second');

expect(emptyKeyCallback).not.toHaveBeenCalled();
expect(keepCallback).toHaveBeenCalledWith('second');
});

it('Should use subscribe effect hook', () => {
const callback = vi.fn();
const data = { id: '1', timestamp: Date.now() };

renderHook(() => eventEmitter.useSubscribeEffect('user:login', callback));

act(() => eventEmitter.push('user:login', data));

expect(callback).toHaveBeenCalledWith(data);
});

it('Should not rerender on subscribe effect hook', () => {
const callback = vi.fn();
const renderCallback = vi.fn();

renderHook(() => {
renderCallback();
eventEmitter.useSubscribeEffect('user:login', callback);
});

const renderCount = renderCallback.mock.calls.length;

act(() => eventEmitter.push('user:login', { id: '1', timestamp: Date.now() }));

expect(callback).toHaveBeenCalledOnce();
expect(renderCallback).toHaveBeenCalledTimes(renderCount);
});

it('Should unsubscribe effect hook on unmount', () => {
const callback = vi.fn();
const { unmount } = renderHook(() => eventEmitter.useSubscribeEffect('user:login', callback));

act(() => eventEmitter.push('user:login', { id: '1', timestamp: Date.now() }));

expect(callback).toHaveBeenCalledOnce();

unmount();

act(() => eventEmitter.push('user:login', { id: '2', timestamp: Date.now() }));

expect(callback).toHaveBeenCalledOnce();
});
Loading