From 1b5dc0365d2fb28bcc5e4afacf5b7102931bbf74 Mon Sep 17 00:00:00 2001 From: Roman Gontar Date: Wed, 2 Sep 2026 13:29:13 +0400 Subject: [PATCH] =?UTF-8?q?feat/create-event-emitter=20=F0=9F=A7=8A=20feat?= =?UTF-8?q?(createEventEmitter):=20=D1=80=D0=B0=D1=81=D1=81=D1=8B=D0=BB?= =?UTF-8?q?=D0=BA=D0=B0=20=D0=BF=D0=BE=20=D1=81=D0=BD=D0=B0=D0=BF=D1=88?= =?UTF-8?q?=D0=BE=D1=82=D1=83,=20once=20=D0=B8=20reset?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Падающий листенер обрывал forEach, подписчики после него не получали событие, а исключение всплывало в месте вызова push. Set.forEach ещё и обходит записи, добавленные во время итерации, поэтому подписка внутри листенера получала текущее событие. - push идёт по копии, ошибка листенера уходит в queueMicrotask - once с отпиской, снимается и по исходной ссылке, как в Node - reset для одного события и для всех - useSubscribeEffect для подписчиков без ререндера - мапа по keyof Events вместо string, убраны четыре as string - объявлен EventEmitterApi, на который уже ссылался JSDoc Листенер, снятый во время рассылки, теперь получает событие в полёте, как в Node и DOM. Это изменение поведения. --- .../createEventEmitter/createEventEmitter.js | 72 +++++- .../createEventEmitter.test.ts | 209 ++++++++++++++++++ .../createEventEmitter/createEventEmitter.ts | 142 ++++++++++-- 3 files changed, 393 insertions(+), 30 deletions(-) diff --git a/packages/core/src/bundle/helpers/createEventEmitter/createEventEmitter.js b/packages/core/src/bundle/helpers/createEventEmitter/createEventEmitter.js index eb8d11a5..0d15eef3 100644 --- a/packages/core/src/bundle/helpers/createEventEmitter/createEventEmitter.js +++ b/packages/core/src/bundle/helpers/createEventEmitter/createEventEmitter.js @@ -6,33 +6,62 @@ import { useEffect, useRef, useState } from 'react'; * @usage low * * @template Events - The type of events and their data - * @returns {EventEmitterApi} - Object containing event emitter methods and hook + * @returns {EventEmitterApi} - 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); @@ -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 }; }; diff --git a/packages/core/src/helpers/createEventEmitter/createEventEmitter.test.ts b/packages/core/src/helpers/createEventEmitter/createEventEmitter.test.ts index b4748d05..d0839824 100644 --- a/packages/core/src/helpers/createEventEmitter/createEventEmitter.test.ts +++ b/packages/core/src/helpers/createEventEmitter/createEventEmitter.test.ts @@ -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 }; } @@ -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', () => { @@ -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(); +}); diff --git a/packages/core/src/helpers/createEventEmitter/createEventEmitter.ts b/packages/core/src/helpers/createEventEmitter/createEventEmitter.ts index a1a7a909..dbe8c349 100644 --- a/packages/core/src/helpers/createEventEmitter/createEventEmitter.ts +++ b/packages/core/src/helpers/createEventEmitter/createEventEmitter.ts @@ -1,5 +1,37 @@ import { useEffect, useRef, useState } from 'react'; +export interface EventEmitterApi> { + /** Subscribes a listener that runs once and then unsubscribes itself */ + once: ( + event: Event, + listener: (data: Events[Event]) => void + ) => () => void; + /** Notifies every listener of the event, in subscription order. Listeners are dispatched over a snapshot, so subscribing inside a listener does not deliver the event being dispatched, while unsubscribing inside one still delivers it. A listener that throws does not stop the rest, its error is rethrown asynchronously through queueMicrotask so it reaches the global error handler. */ + push: (event: Event, data: Events[Event]) => void; + /** Removes every listener of the event, or of all events when called without arguments */ + reset: (event?: Event) => void; + /** Subscribes a listener and returns a function that unsubscribes it */ + subscribe: ( + event: Event, + listener: (data: Events[Event]) => void + ) => () => void; + /** Removes a listener registered with subscribe or once */ + unsubscribe: ( + event: Event, + listener: (data: Events[Event]) => void + ) => void; + /** Hook that returns the last received data and rerenders the component on every push */ + useSubscribe: ( + event: Event, + listener?: (data: Events[Event]) => void + ) => Events[Event] | undefined; + /** Hook that runs a listener on every push without rerendering the component */ + useSubscribeEffect: ( + event: Event, + listener: (data: Events[Event]) => void + ) => void; +} + /** * @name createEventEmitter * @description - Creates a type-safe event emitter @@ -7,38 +39,65 @@ import { useEffect, useRef, useState } from 'react'; * @usage low * * @template Events - The type of events and their data - * @returns {EventEmitterApi} - Object containing event emitter methods and hook + * @returns {EventEmitterApi} - 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 = = Record>() => { - type ListenerMap = Map void>>; +export const createEventEmitter = < + Events extends Record = Record +>(): EventEmitterApi => { + type Listener = ((data: any) => void) & { listener?: (data: any) => void }; + type ListenerMap = Map>; const listeners: ListenerMap = new Map(); const push = (event: Event, data: Events[Event]) => { - const eventListeners = listeners.get(event as string); - eventListeners?.forEach((listener) => listener(data)); + const eventListeners = listeners.get(event); + if (!eventListeners) return; + const eventListenersSnapshot = [...eventListeners]; + + for (const listener of eventListenersSnapshot) { + try { + listener(data); + } catch (error) { + queueMicrotask(() => { + throw error; + }); + } + } }; - const unsubscribe = ( - event: Key, - listener: (data: Events[Key]) => void + const unsubscribe = ( + event: Event, + listener: (data: Events[Event]) => void ) => { - const eventKey = event as string; - 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: Key, - listener: (data: Events[Key]) => void + const subscribe = ( + event: Event, + listener: (data: Events[Event]) => void ) => { - const eventKey = event as string; - if (!listeners.has(eventKey)) listeners.set(eventKey, new Set()); - const eventListeners = listeners.get(event as string)!; + if (!listeners.has(event)) listeners.set(event, new Set()); + const eventListeners = listeners.get(event)!; eventListeners.add(listener); return () => { @@ -46,6 +105,19 @@ export const createEventEmitter = = Record( + event: Event, + listener: (data: Events[Event]) => void + ) => { + const wrapper: Listener = (data) => { + unsubscribe(event, wrapper); + listener(data); + }; + wrapper.listener = listener; + + return subscribe(event, wrapper); + }; + const useSubscribe = ( event: Event, listener?: (data: Events[Event]) => void @@ -69,10 +141,40 @@ export const createEventEmitter = = Record( + event: Event, + listener: (data: Events[Event]) => void + ) => { + const listenerRef = useRef(listener); + listenerRef.current = listener; + + useEffect(() => { + const onSubscribe = (data: Events[Event]) => { + listenerRef.current?.(data); + }; + + const unsubscribe = subscribe(event, onSubscribe); + return () => { + unsubscribe(); + }; + }, [event]); + }; + + const reset = (event?: Event) => { + if (event !== undefined) { + listeners.delete(event); + } else { + listeners.clear(); + } + }; + return { push, subscribe, unsubscribe, - useSubscribe + useSubscribe, + reset, + useSubscribeEffect, + once }; };